Library pick: charmbracelet/huh for the live prompter
byob-prompter.2
concurrencycontextdeps-philosophyprompter
Problem: every prompt library has trade-offs. AlecAivazis/survey/v2
is in maintenance mode; charmbracelet/huh is actively maintained
and pretty; raw bubbletea is too low-level to be a prompter. A
stdlib-only live prompter is doable (~120 lines + golang.org/x/term
for no-echo passwords) but ships an underwhelming Select/MultiSelect
UX compared to an arrow-key picker.
Idea: ship charmbracelet/huh as the default prompt.NewLive impl.
It covers Confirm / Input / Password / Select / MultiSelect with a
consistent TTY UX, degrades deterministically when stdin isn't a
terminal (coordinated with byob-prompter.3's ErrNotTTY sentinel), and
is actively maintained by a team that ships other CLI-adjacent
libraries.
Because the Prompter is a narrow interface (byob-prompter.1), huh's types never leak to callers; swapping the impl later is mechanical.
Tradeoffs:
- Transitive dep weight. huh pulls bubbletea + lipgloss. Against
byob-release.8's pure-Go minimalism the UX win is worth the weight,
but it IS a meaningful ask — flag it explicitly in
go.modreview. - Known risk: API churn. Charmbracelet's ecosystem has shipped
breaking changes across minor/major revisions more than once.
The narrow interface insulates callers, but the
liveimpl in this file will need rework on those bumps. Treat thehuhpin ingo.modas a conscious version choice, not a floating dependency, and expect to spend time on updates. - Known risk: context cancellation is best-effort.
huh.Run()is blocking and has no nativecontext.Contextawareness, so theliveimpl runs huh in a goroutine and selects onctx.Done(). On cancellation the prompt goroutine leaks until the user hits a key (or the process exits); the caller getsctx.Err()immediately, which is what matters for command shutdown. - Fallback if the above risks bite. Switch to a stdlib-only
impl (
bufio.Scanner+golang.org/x/termfor no-echo passwords) orAlecAivazis/survey/v2(stable API, maintenance- mode but functional). Both satisfy the samePrompterinterface.
Design
// pkg/cmd/prompt/live.go
package prompt
import "github.com/charmbracelet/huh"
type live struct { io *iostreams.IOStreams }
func NewLive(io *iostreams.IOStreams) Prompter { return &live{io: io} }
func (p *live) Confirm(ctx context.Context, msg string, def bool) (bool, error) {
if !p.io.IsStdinTTY() { return false, ErrNotTTY }
v := def // huh reads the initial value as the default selection
errCh := make(chan error, 1)
go func() {
errCh <- huh.NewConfirm().
Title(msg).
Affirmative("Yes").
Negative("No").
Value(&v).
WithTheme(huh.ThemeBase()).
Run()
}()
select {
case err := <-errCh:
if err != nil { return false, err }
return v, nil
case <-ctx.Done():
// huh has no ctx hook; the goroutine keeps running until the
// user hits a key or the process exits. Caller gets ctx.Err()
// immediately — that's the contract that matters for shutdown.
return false, ctx.Err()
}
}
func (p *live) Select(ctx context.Context, msg string, options []string) (int, error) {
if !p.io.IsStdinTTY() { return 0, ErrNotTTY }
opts := make([]huh.Option[int], len(options))
for i, o := range options { opts[i] = huh.NewOption(o, i) }
var v int
errCh := make(chan error, 1)
go func() {
errCh <- huh.NewSelect[int]().Title(msg).Options(opts...).Value(&v).Run()
}()
select {
case err := <-errCh:
return v, err
case <-ctx.Done():
return 0, ctx.Err()
}
}
// Input/Password/MultiSelect follow the same ctx-select shape.
Tests never instantiate live — they use prompt.Stub (byob-prompter.4),
so huh never runs in the test path.