cli: do not use interactive TUI elements when control terminal (tty) is not available (fixes #386)

This commit is contained in:
Pasha Sviderski
2026-06-18 18:22:48 +10:00
parent 7bb3cb9682
commit 2f717a10a7
10 changed files with 81 additions and 56 deletions
+16 -1
View File
@@ -25,7 +25,9 @@ func Confirm(title string) (bool, error) {
Value(&confirmed),
),
).WithTheme(ThemeConfirm()).
WithAccessible(true)
WithAccessible(true).
// Render to stderr so stdout stays clean for command output.
WithOutput(os.Stderr)
if err := form.Run(); err != nil {
return false, err
}
@@ -51,6 +53,14 @@ func ThemeConfirmDanger() huh.Theme {
})
}
// IsTerminalAvailable reports whether the control terminal (TTY) is available so an interactive TUI can run.
func IsTerminalAvailable() bool {
// Bubbletea interactive programs read keyboard input from stdin and render to stdout (default) or stderr (uncloud),
// so both must be terminals. In particular, bubbletea falls back to opening /dev/tty when stdin is not a terminal,
// which fails when there is no controlling terminal. See https://github.com/psviderski/uncloud/issues/386
return IsStdinTerminal() && IsStderrTerminal()
}
// IsStdinTerminal checks if the standard input is a terminal (TTY).
func IsStdinTerminal() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
@@ -61,6 +71,11 @@ func IsStdoutTerminal() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}
// IsStderrTerminal checks if the standard error is a terminal (TTY).
func IsStderrTerminal() bool {
return term.IsTerminal(int(os.Stderr.Fd()))
}
// TerminalWidth returns the width of the terminal.
// Returns 0 if stdout is not a terminal or the width cannot be determined.
func TerminalWidth() int {
+37
View File
@@ -0,0 +1,37 @@
package tui
import (
"context"
"fmt"
"os"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2"
)
// RunSpinner shows an animated spinner that prints title while running action.
// When the terminal (TTY) is not available, it prints the title as plain text.
// It renders to stderr so stdout stays clean for command output.
func RunSpinner(ctx context.Context, title string, action func(ctx context.Context) error) error {
// Fall back to plain text when a terminal is not available to avoid the bubbletea /dev/tty error and to keep
// escape codes out of redirected output.
if !IsTerminalAvailable() {
fmt.Fprintln(os.Stderr, title)
return action(ctx)
}
return spinner.New().
// Leading space offsets the title from the spinner glyph.
Title(" " + title).
Type(spinner.MiniDot).
WithTheme(spinner.ThemeFunc(func(isDark bool) *spinner.Styles {
return &spinner.Styles{
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Title: lipgloss.NewStyle(),
}
})).
WithOutput(os.Stderr).
Context(ctx).
ActionWithErr(action).
Run()
}