From 87426ee236c3918827fcc05e4aafe5dafcc09368 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:13:32 -0700 Subject: [PATCH] Add experimental huh-only prompter gated by GH_EXPERIMENTAL_PROMPTER Introduce a new Prompter implementation (huhPrompter) that uses the charmbracelet/huh library in its standard interactive mode, as an alternative to the survey-based default prompter. The new implementation is gated behind the GH_EXPERIMENTAL_PROMPTER environment variable, following the same truthy/falsey pattern as GH_ACCESSIBLE_PROMPTER. Key differences from the accessible prompter: - No WithAccessible(true) flag (full interactive TUI) - Uses EchoModePassword (masked with *) instead of EchoModeNone - No default value annotations appended to prompt text Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/prompter/huh_prompter.go | 226 ++++++++++++++++++++++++++++++ internal/prompter/prompter.go | 9 ++ pkg/cmd/factory/default.go | 7 + pkg/iostreams/iostreams.go | 13 +- 4 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 internal/prompter/huh_prompter.go diff --git a/internal/prompter/huh_prompter.go b/internal/prompter/huh_prompter.go new file mode 100644 index 000000000..66738d04f --- /dev/null +++ b/internal/prompter/huh_prompter.go @@ -0,0 +1,226 @@ +package prompter + +import ( + "fmt" + "slices" + + "github.com/charmbracelet/huh" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/pkg/surveyext" + ghPrompter "github.com/cli/go-gh/v2/pkg/prompter" +) + +type huhPrompter struct { + stdin ghPrompter.FileReader + stdout ghPrompter.FileWriter + stderr ghPrompter.FileWriter + editorCmd string +} + +func (p *huhPrompter) newForm(groups ...*huh.Group) *huh.Form { + return huh.NewForm(groups...). + WithTheme(huh.ThemeBase16()). + WithInput(p.stdin). + WithOutput(p.stdout) +} + +func (p *huhPrompter) Select(prompt, defaultValue string, options []string) (int, error) { + var result int + + if !slices.Contains(options, defaultValue) { + defaultValue = "" + } + + formOptions := make([]huh.Option[int], len(options)) + for i, o := range options { + if defaultValue == o { + result = i + } + formOptions[i] = huh.NewOption(o, i) + } + + err := p.newForm( + huh.NewGroup( + huh.NewSelect[int](). + Title(prompt). + Value(&result). + Options(formOptions...), + ), + ).Run() + + return result, err +} + +func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { + var result []int + + defaults = slices.DeleteFunc(defaults, func(s string) bool { + return !slices.Contains(options, s) + }) + + formOptions := make([]huh.Option[int], len(options)) + for i, o := range options { + if slices.Contains(defaults, o) { + result = append(result, i) + } + formOptions[i] = huh.NewOption(o, i) + } + + err := p.newForm( + huh.NewGroup( + huh.NewMultiSelect[int](). + Title(prompt). + Value(&result). + Limit(len(options)). + Options(formOptions...), + ), + ).Run() + + if err != nil { + return nil, err + } + return result, nil +} + +func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) { + return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc) +} + +func (p *huhPrompter) Input(prompt, defaultValue string) (string, error) { + result := defaultValue + + err := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title(prompt). + Value(&result), + ), + ).Run() + + return result, err +} + +func (p *huhPrompter) Password(prompt string) (string, error) { + var result string + + err := p.newForm( + huh.NewGroup( + huh.NewInput(). + EchoMode(huh.EchoModePassword). + Title(prompt). + Value(&result), + ), + ).Run() + + if err != nil { + return "", err + } + return result, nil +} + +func (p *huhPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { + result := defaultValue + + err := p.newForm( + huh.NewGroup( + huh.NewConfirm(). + Title(prompt). + Value(&result), + ), + ).Run() + + if err != nil { + return false, err + } + return result, nil +} + +func (p *huhPrompter) AuthToken() (string, error) { + var result string + + err := p.newForm( + huh.NewGroup( + huh.NewInput(). + EchoMode(huh.EchoModePassword). + Title("Paste your authentication token:"). + Validate(func(input string) error { + if input == "" { + return fmt.Errorf("token is required") + } + return nil + }). + Value(&result), + ), + ).Run() + + return result, err +} + +func (p *huhPrompter) ConfirmDeletion(requiredValue string) error { + return 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 + }), + ), + ).Run() +} + +func (p *huhPrompter) InputHostname() (string, error) { + var result string + + err := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title("Hostname:"). + Validate(ghinstance.HostnameValidator). + Value(&result), + ), + ).Run() + + if err != nil { + return "", err + } + return result, nil +} + +func (p *huhPrompter) 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)) + } + + err := p.newForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(prompt). + Options(options...). + Value(&result), + ), + ).Run() + + if err != nil { + return "", err + } + + if result == skipOption { + return "", nil + } + + text, err := surveyext.Edit(p.editorCmd, "*.md", defaultValue, p.stdin, p.stdout, p.stderr) + if err != nil { + return "", err + } + + return text, nil +} diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 2bf49eb58..40b746839 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -53,6 +53,15 @@ type Prompter interface { } 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, diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 48ec0c8fe..cdbed20af 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -316,6 +316,13 @@ func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams { io.SetAccessiblePrompterEnabled(true) } + experimentalPrompterValue, experimentalPrompterIsSet := os.LookupEnv("GH_EXPERIMENTAL_PROMPTER") + if experimentalPrompterIsSet { + if !slices.Contains(falseyValues, experimentalPrompterValue) { + io.SetExperimentalPrompterEnabled(true) + } + } + ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED") if ghSpinnerDisabledIsSet { if !slices.Contains(falseyValues, ghSpinnerDisabledValue) { diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 22f966ac8..89d4600c0 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -79,8 +79,9 @@ type IOStreams struct { pagerCommand string pagerProcess *os.Process - neverPrompt bool - accessiblePrompterEnabled bool + neverPrompt bool + accessiblePrompterEnabled bool + experimentalPrompterEnabled bool TempFileOverride *os.File } @@ -466,6 +467,14 @@ func (s *IOStreams) AccessiblePrompterEnabled() bool { return s.accessiblePrompterEnabled } +func (s *IOStreams) SetExperimentalPrompterEnabled(enabled bool) { + s.experimentalPrompterEnabled = enabled +} + +func (s *IOStreams) ExperimentalPrompterEnabled() bool { + return s.experimentalPrompterEnabled +} + func System() *IOStreams { terminal := ghTerm.FromEnv()