Files
summercms/modules/bonfire/prompts.go
Jakub Zych 3f38862b22 feat(bonfire): SSU widgets on charm v2 behind the Output interface
- Spinner, gradient progress bar and the multi-row process view ported
  from SSU to bubbletea v2 (input off, no signal handler, command
  context); boxed tables through lipgloss/table
- Prompts use huh fields and a single-select port of SSU's selector with
  its confirmation screen on an interactive terminal; line prompts off a
  terminal and under --no-ansi are unchanged (piped passwords still work)
- -q hides decoration but keeps data and errors; -n returns defaults and
  Secret fails with a hint on a terminal stdin; --no-ansi and non-TTY
  runs print today's plain lines
- Helpers: GlobalsOf, VerbosityOf, IsQuiet, Interactive, ConfirmProceed,
  Process, Current, Failed, PrintAbove, ItemProgress, ErrAborted
- diary's styled console lines print above a running live view
2026-10-21 12:30:53 +02:00

236 lines
5.7 KiB
Go

package bonfire
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"golang.org/x/term"
)
// ErrAborted is returned by a prompt that the person cancelled (esc or
// ctrl+c on a terminal).
var ErrAborted = errors.New("bonfire: prompt aborted")
// errSecretNoInteraction is the error of Secret under -n when stdin is a
// terminal: a secret has no default.
var errSecretNoInteraction = errors.New("bonfire: a secret is needed and -n turned prompts off")
// richPrompts reports whether prompts use huh and the selector: the run is
// interactive and --no-ansi is not set (with --no-ansi a terminal gets the
// plain line prompts).
func (c *console) richPrompts() bool {
return c.canPrompt() && c.globals.Color != ColorNever
}
// runForm runs a huh form on the console's streams.
func (c *console) runForm(field huh.Field) error {
err := huh.NewForm(huh.NewGroup(field)).WithInput(c.in).WithOutput(c.out).Run()
if errors.Is(err, huh.ErrUserAborted) {
return ErrAborted
}
return err
}
func (c *console) Ask(question, def string) (string, error) {
if c.globals.NoInteraction {
return def, nil
}
if c.richPrompts() {
var answer string
input := huh.NewInput().Title(question).Value(&answer)
if def != "" {
input = input.Placeholder(def)
}
if err := c.runForm(input); err != nil {
return "", err
}
if strings.TrimSpace(answer) == "" {
return def, nil
}
return strings.TrimSpace(answer), nil
}
c.writeQuestion(question, def)
line, err := c.readLine()
if err != nil {
if err == io.EOF {
return def, nil
}
return "", err
}
if line == "" {
return def, nil
}
return line, nil
}
func (c *console) Confirm(question string, def bool) (bool, error) {
if c.globals.NoInteraction || !c.interactive {
return def, nil
}
if c.richPrompts() {
answer := def
if err := c.runForm(huh.NewConfirm().Title(question).Value(&answer)); err != nil {
return false, err
}
return answer, nil
}
hint := "y/N"
if def {
hint = "Y/n"
}
line, err := c.Ask(question+" ["+hint+"]", "")
if err != nil {
return false, err
}
switch strings.ToLower(line) {
case "y", "yes":
return true, nil
case "n", "no":
return false, nil
default:
return def, nil
}
}
func (c *console) Choice(question string, choices []string, def int) (string, error) {
if len(choices) == 0 {
return "", fmt.Errorf("bonfire: choice has no options")
}
if def < 0 || def >= len(choices) {
def = 0
}
if c.globals.NoInteraction {
return choices[def], nil
}
if c.richPrompts() {
return c.selectOne(question, choices, def)
}
c.Println(c.styledText(c.palette.Current.Render(symPrompt)) + " " + question)
for i, choice := range choices {
mark := " "
if i == def {
mark = "*"
}
c.Printf(" %s %d) %s\n", mark, i+1, choice)
}
line, err := c.readLine()
if err != nil {
if err == io.EOF {
return choices[def], nil
}
return "", err
}
if line == "" {
return choices[def], nil
}
n, convErr := strconv.Atoi(line)
if convErr != nil || n < 1 || n > len(choices) {
return choices[def], nil
}
return choices[n-1], nil
}
// selectOne runs the ported selector on the terminal.
func (c *console) selectOne(question string, choices []string, def int) (string, error) {
prog := tea.NewProgram(newSelectorModel(c.palette, question, choices, def),
tea.WithInput(c.in),
tea.WithOutput(c.out),
tea.WithoutSignalHandler(),
tea.WithColorProfile(c.profile()),
)
final, err := prog.Run()
if err != nil {
return "", err
}
m, ok := final.(selectorModel)
if !ok || m.cancelled || m.chosen < 0 {
return "", ErrAborted
}
return choices[m.chosen], nil
}
// Secret reads a hidden value: through a password field on an interactive
// terminal, through term.ReadPassword when only stdin is a terminal, and as
// a plain line when stdin is not a terminal (piped input). Under -n a
// terminal stdin fails with a hint, because a secret has no default.
func (c *console) Secret(question string) (string, error) {
if c.globals.NoInteraction {
if c.inTTY {
return "", WithHint(errSecretNoInteraction,
"This command reads the value from a prompt or from stdin.",
`printf '%s\n' "$VALUE" | {bin} <command> -n`)
}
return c.readSecretLine()
}
if c.richPrompts() {
var answer string
if err := c.runForm(huh.NewInput().Title(question).EchoMode(huh.EchoModePassword).Value(&answer)); err != nil {
return "", err
}
return answer, nil
}
c.writeQuestion(question, "")
if f, ok := c.in.(*os.File); ok && c.inTTY {
b, err := term.ReadPassword(int(f.Fd()))
c.Println()
if err != nil {
return "", err
}
return string(b), nil
}
return c.readSecretLine()
}
// readSecretLine reads one line of piped input; the value is never logged.
func (c *console) readSecretLine() (string, error) {
line, err := c.readLine()
if err != nil {
if err == io.EOF {
return "", nil
}
return "", err
}
return line, nil
}
// styledText applies the color profile to s for output written with Printf.
func (c *console) styledText(s string) string {
var b strings.Builder
_, _ = io.WriteString(c.styled(&b), s)
return b.String()
}
func (c *console) writeQuestion(question, def string) {
suffix := ""
if def != "" {
suffix = " " + c.palette.Muted.Render("("+def+")")
}
c.Printf("%s", c.styledText(c.palette.Current.Render(symPrompt)+" "+question+suffix+" "+symArrow+" "))
}
func (c *console) readLine() (string, error) {
if c.in == nil {
return "", io.EOF
}
reader, ok := c.in.(*bufio.Reader)
if !ok {
reader = bufio.NewReader(c.in)
}
line, err := reader.ReadString('\n')
line = strings.TrimRight(line, "\r\n")
if err != nil && err != io.EOF {
return "", err
}
if err == io.EOF && line == "" {
return "", io.EOF
}
return strings.TrimSpace(line), nil
}