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>
This commit is contained in:
Kynan Ware 2026-03-06 19:13:32 -07:00 committed by William Martin
parent 39407e65e3
commit 87426ee236
4 changed files with 253 additions and 2 deletions

View file

@ -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
}

View file

@ -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,

View file

@ -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) {

View file

@ -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()