Files
gitea-tea/internal/terminal/streams.go
T
bircni 62962b4f70 feat(terminal): add confirmation defaults and fix form input loss
Every confirmation defaulted to No, so Enter after a filled-in form
canceled it; prompts were titled with raw flag names and aborted on an
empty answer; and the shared issue and pull request form lost input:

- Multi-selects hid their last option, so a single assignee looked like
  an empty list and labels showed four of five. They now get an explicit
  height and scroll past eight options.
- A pty without a window size, such as `script` run without a terminal,
  made bubbles panic. Forms now refuse it with "terminal size unknown;
  pass the values as flags or use --no-input".
- Without a configured editor, the form ran to the end and then failed
  with "configure an editor", discarding everything typed. The editor is
  now resolved before the form; without one the body is typed into an
  inline multi-line field.
- The pull request form said "Issue title" and "Issue description". The
  shared fields read "Title (required):" and "Body (markdown):", and the
  review body label gained its missing space.

New prompt API for the area commands: confirmAction with a
terminal.Confirmation (DefaultYes for constructive actions, Typed for
type-to-confirm, NonTTY to name the action in the --yes error) and
requireInput with a human label and inline validation. Declined or
aborted prompts return terminal.ErrCanceled (exit code 3). The existing
confirm and require helpers keep working on top of them, and prompters
without the new terminal.Asker methods keep the plain behavior.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-11 22:43:11 +02:00

276 lines
9.9 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terminal
import (
"context"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"charm.land/huh/v2"
"gitea.dev/tea/internal/clierr"
"golang.org/x/term"
)
// Streams records injected IO and independently detected terminal capabilities.
type Streams struct {
In io.Reader
Out io.Writer
ErrOut io.Writer
InTTY bool
OutTTY bool
ErrTTY bool
// Width is the number of columns human output on Out fits in; 0 means unlimited.
Width int
}
// System binds streams to the current process and detects each terminal.
func System() Streams {
return Streams{
In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr,
InTTY: term.IsTerminal(int(os.Stdin.Fd())), OutTTY: term.IsTerminal(int(os.Stdout.Fd())), ErrTTY: term.IsTerminal(int(os.Stderr.Fd())),
Width: terminalWidth(int(os.Stdout.Fd()), os.Getenv),
}
}
// terminalWidth returns the width of the terminal at fd, then $COLUMNS, then 80. A pty without a
// window size, such as `script` run without a terminal, reports a width of 0.
func terminalWidth(fd int, getenv func(string) string) int {
if width, _, err := term.GetSize(fd); err == nil && width > 0 {
return width
}
if width, err := strconv.Atoi(getenv("COLUMNS")); err == nil && width > 0 {
return width
}
return 80
}
// Policy controls input interaction independently of supplied flag count.
type Policy struct{ Enabled, NoInput, Machine, Interactive bool }
// CanPrompt requires terminal input and diagnostics, without requiring terminal stdout.
func (p Policy) CanPrompt(s Streams) bool {
return p.Enabled && !p.NoInput && (!p.Machine || p.Interactive) && s.InTTY && s.ErrTTY
}
// Validate rejects an explicit interactive request incompatible with terminal policy.
func (p Policy) Validate(s Streams) error {
if p.Interactive && !p.CanPrompt(s) {
return clierr.Wrap(clierr.Usage, errors.New("--interactive requires terminal stdin/stderr, enabled prompting, and no --no-input"))
}
return nil
}
// ErrCanceled reports a declined confirmation or an aborted prompt. It exits with code 3 and is
// meant to be reported as "Canceled." rather than as an error.
var ErrCanceled = clierr.Wrap(clierr.Canceled, errors.New("canceled"))
// Prompter abstracts cancellable input collection and confirmation.
type Prompter interface {
Input(context.Context, string) (string, error)
SecretInput(context.Context, string) (string, error)
Confirm(context.Context, string) (bool, error)
}
// Asker is the richer prompt API behind ConfirmAction and RequireInput: inline validation,
// multi-line text and confirmations with a default answer. FormPrompter implements it; a
// Prompter without it gets plain Input and Confirm behavior.
type Asker interface {
// Ask collects one line, re-prompting inline until validate accepts it.
Ask(ctx context.Context, title string, validate func(string) error) (string, error)
// AskText collects multi-line markdown, starting from initial.
AskText(ctx context.Context, title, initial string) (string, error)
// ConfirmDefault asks a yes/no question that Enter answers with defaultYes.
ConfirmDefault(ctx context.Context, title string, defaultYes bool) (bool, error)
}
// FormPrompter uses cancellable terminal forms with explicitly injected streams.
type FormPrompter struct {
Streams Streams
// size reports the size of the terminal that forms render on; nil queries ErrOut.
size func() (width, height int, err error)
}
func promptError(ctx context.Context, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if errors.Is(err, huh.ErrUserAborted) {
return ErrCanceled
}
return err
}
// errNoTerminalSize refuses forms on a terminal without a size, such as a pty that `script` opens
// without a terminal. Forms size their fields from it, and a zero size panics in bubbles; nobody
// could see the form there anyway.
var errNoTerminalSize = clierr.Wrap(clierr.Usage, errors.New("terminal size unknown; pass the values as flags or use --no-input"))
func (p *FormPrompter) runForm(ctx context.Context, form *huh.Form) error {
size := p.size
if file, ok := p.Streams.ErrOut.(*os.File); ok && size == nil {
size = func() (int, int, error) { return term.GetSize(int(file.Fd())) }
}
if size != nil {
if width, height, err := size(); err == nil && (width <= 0 || height <= 0) {
return errNoTerminalSize
}
}
return promptError(ctx, form.WithInput(p.Streams.In).WithOutput(p.Streams.ErrOut).RunWithContext(ctx))
}
// Input collects one field using the configured terminal streams.
func (p *FormPrompter) Input(ctx context.Context, title string) (string, error) {
return p.Ask(ctx, title, nil)
}
// Ask collects one line, re-prompting inline until validate accepts the trimmed answer.
func (p *FormPrompter) Ask(ctx context.Context, title string, validate func(string) error) (string, error) {
var value string
input := huh.NewInput().Title(title).Value(&value)
if validate != nil {
input = input.Validate(func(s string) error { return validate(strings.TrimSpace(s)) })
}
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(input)))
return strings.TrimSpace(value), err
}
// AskText collects multi-line markdown in an inline field, starting from initial.
func (p *FormPrompter) AskText(ctx context.Context, title, initial string) (string, error) {
value := initial
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(bodyField(title, &value))))
return value, err
}
// SecretInput collects a value without displaying it on the terminal.
func (p *FormPrompter) SecretInput(ctx context.Context, title string) (string, error) {
var value string
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewInput().Title(title).EchoMode(huh.EchoModePassword).Value(&value))))
return value, err
}
// Confirm asks a yes/no question that Enter answers with no.
func (p *FormPrompter) Confirm(ctx context.Context, title string) (bool, error) {
return p.ConfirmDefault(ctx, title, false)
}
// ConfirmDefault asks a yes/no question that Enter answers with defaultYes.
func (p *FormPrompter) ConfirmDefault(ctx context.Context, title string, defaultYes bool) (bool, error) {
accepted := defaultYes
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewConfirm().Title(title).Value(&accepted))))
return accepted, err
}
// RequireInput fills a missing value from a prompt titled label, re-prompting inline until the
// answer is non-empty and validate, when set, accepts it. When prompts are unavailable it returns
// a usage error naming --flag. It reports whether it prompted.
func RequireInput(ctx context.Context, value *string, flag, label string, validate func(string) error, streams Streams, policy Policy, prompt Prompter) (bool, error) {
if *value != "" {
return false, nil
}
if !policy.CanPrompt(streams) {
return false, clierr.Wrap(clierr.Usage, fmt.Errorf("missing required input: supply --%s", flag))
}
var entered string
var err error
if asker, ok := prompt.(Asker); ok {
entered, err = asker.Ask(ctx, label, func(s string) error {
if s == "" {
return errors.New("a value is required")
}
if validate != nil {
return validate(s)
}
return nil
})
} else if entered, err = prompt.Input(ctx, label); err == nil {
switch {
case entered == "":
err = clierr.Wrap(clierr.Usage, fmt.Errorf("--%s cannot be empty", flag))
case validate != nil:
if invalid := validate(entered); invalid != nil {
err = clierr.Wrap(clierr.Usage, fmt.Errorf("--%s: %w", flag, invalid))
}
}
}
if err != nil {
return false, err
}
*value = entered
return true, nil
}
// Require is RequireInput titled by the flag name, for callers without a human label.
func Require(ctx context.Context, value *string, flag string, streams Streams, policy Policy, prompt Prompter) (bool, error) {
return RequireInput(ctx, value, flag, flag, nil, streams, policy, prompt)
}
// Confirmation describes the question asked before an action.
type Confirmation struct {
// Prompt asks about the action in sentence case: `Delete label "bug" from alice/x?`.
Prompt string
// DefaultYes lets Enter accept, for constructive actions the user just asked for, such as
// submitting a form they filled in. Destructive actions keep the default no.
DefaultYes bool
// Typed, when set, must be typed to confirm, for irreversible actions such as deleting a repository.
Typed string
// NonTTY names the action in the error returned when no prompt is possible and --yes is
// missing: `delete label "bug" from alice/x`.
NonTTY string
}
// ConfirmAction asks c unless yes is set. A declined or aborted confirmation returns ErrCanceled.
func ConfirmAction(ctx context.Context, c Confirmation, yes bool, streams Streams, policy Policy, prompt Prompter) error {
if yes {
return nil
}
if !policy.CanPrompt(streams) {
action := ""
if c.NonTTY != "" {
action = " to " + c.NonTTY
}
return clierr.Wrap(clierr.Usage, fmt.Errorf("confirmation required%s; rerun with --yes", action))
}
asker, rich := prompt.(Asker)
if c.Typed != "" {
title := c.Prompt + " Type " + c.Typed + " to confirm:"
matches := func(s string) bool { return strings.EqualFold(strings.TrimSpace(s), c.Typed) }
if rich {
_, err := asker.Ask(ctx, title, func(s string) error {
if !matches(s) {
return fmt.Errorf("type %s to confirm", c.Typed)
}
return nil
})
return err
}
typed, err := prompt.Input(ctx, title)
if err == nil && !matches(typed) {
err = ErrCanceled
}
return err
}
var accepted bool
var err error
if rich {
accepted, err = asker.ConfirmDefault(ctx, c.Prompt, c.DefaultYes)
} else {
accepted, err = prompt.Confirm(ctx, c.Prompt)
}
if err == nil && !accepted {
err = ErrCanceled
}
return err
}
// Confirm requires explicit acceptance unless confirmation was supplied by flag.
// It is ConfirmAction for callers that predate Confirmation.
func Confirm(ctx context.Context, description string, yes bool, streams Streams, policy Policy, prompt Prompter) error {
return ConfirmAction(ctx, Confirmation{Prompt: description}, yes, streams, policy, prompt)
}