fix(cli): route interactive ui to stderr

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 11:16:05 +00:00
parent f8ba7be626
commit 4f7a4c3a20
5 changed files with 80 additions and 23 deletions

View file

@ -114,15 +114,15 @@ func Dim(msg string) {
func Progress(verb string, current, total int, item ...string) {
msg := i18n.Progress(verb)
if len(item) > 0 && item[0] != "" {
fmt.Printf("\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, item[0])
fmt.Fprintf(os.Stderr, "\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, item[0])
} else {
fmt.Printf("\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total)
fmt.Fprintf(os.Stderr, "\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total)
}
}
// ProgressDone clears the progress line.
func ProgressDone() {
fmt.Print("\033[2K\r")
fmt.Fprint(os.Stderr, "\033[2K\r")
}
// Label prints a "Label: value" line.

View file

@ -35,9 +35,9 @@ func Prompt(label, defaultVal string) (string, error) {
label = compileGlyphs(label)
defaultVal = compileGlyphs(defaultVal)
if defaultVal != "" {
fmt.Printf("%s [%s]: ", label, defaultVal)
fmt.Fprintf(os.Stderr, "%s [%s]: ", label, defaultVal)
} else {
fmt.Printf("%s: ", label)
fmt.Fprintf(os.Stderr, "%s: ", label)
}
r := newReader()
@ -66,11 +66,11 @@ func Select(label string, options []string) (string, error) {
return "", nil
}
fmt.Println(compileGlyphs(label))
fmt.Fprintln(os.Stderr, compileGlyphs(label))
for i, opt := range options {
fmt.Printf(" %d. %s\n", i+1, compileGlyphs(opt))
fmt.Fprintf(os.Stderr, " %d. %s\n", i+1, compileGlyphs(opt))
}
fmt.Printf("Choose [1-%d]: ", len(options))
fmt.Fprintf(os.Stderr, "Choose [1-%d]: ", len(options))
r := newReader()
input, err := r.ReadString('\n')
@ -94,11 +94,11 @@ func MultiSelect(label string, options []string) ([]string, error) {
return []string{}, nil
}
fmt.Println(compileGlyphs(label))
fmt.Fprintln(os.Stderr, compileGlyphs(label))
for i, opt := range options {
fmt.Printf(" %d. %s\n", i+1, compileGlyphs(opt))
fmt.Fprintf(os.Stderr, " %d. %s\n", i+1, compileGlyphs(opt))
}
fmt.Printf("Choose (space-separated) [1-%d]: ", len(options))
fmt.Fprintf(os.Stderr, "Choose (space-separated) [1-%d]: ", len(options))
r := newReader()
input, err := r.ReadString('\n')

View file

@ -38,6 +38,49 @@ func captureStderr(t *testing.T, fn func()) string {
return buf.String()
}
func captureStdoutStderr(t *testing.T, fn func()) (string, string) {
t.Helper()
oldOut := os.Stdout
oldErr := os.Stderr
rOut, wOut, err := os.Pipe()
if !assert.NoError(t, err) {
return "", ""
}
rErr, wErr, err := os.Pipe()
if !assert.NoError(t, err) {
return "", ""
}
os.Stdout = wOut
os.Stderr = wErr
defer func() {
os.Stdout = oldOut
os.Stderr = oldErr
}()
fn()
if !assert.NoError(t, wOut.Close()) {
return "", ""
}
if !assert.NoError(t, wErr.Close()) {
return "", ""
}
var outBuf bytes.Buffer
var errBuf bytes.Buffer
_, err = io.Copy(&outBuf, rOut)
if !assert.NoError(t, err) {
return "", ""
}
_, err = io.Copy(&errBuf, rErr)
if !assert.NoError(t, err) {
return "", ""
}
return outBuf.String(), errBuf.String()
}
func TestPrompt_Good(t *testing.T) {
SetStdin(strings.NewReader("hello\n"))
defer SetStdin(nil) // reset
@ -353,3 +396,17 @@ func TestPromptHints_Good_UseStderr(t *testing.T) {
assert.Contains(t, stderr.String(), "try again")
assert.Contains(t, stderr.String(), "invalid")
}
func TestPrompt_Good_WritesToStderr(t *testing.T) {
SetStdin(strings.NewReader("hello\n"))
defer SetStdin(nil)
stdout, stderr := captureStdoutStderr(t, func() {
val, err := Prompt("Name", "")
assert.NoError(t, err)
assert.Equal(t, "hello", val)
})
assert.Empty(t, stdout)
assert.Contains(t, stderr, "Name:")
}

View file

@ -114,7 +114,7 @@ func (tr *TaskTracker) Snapshots() iter.Seq2[string, string] {
// NewTaskTracker creates a new parallel task tracker.
func NewTaskTracker() *TaskTracker {
return &TaskTracker{out: os.Stdout}
return &TaskTracker{out: os.Stderr}
}
// Add registers a task and returns it for goroutine use.

View file

@ -110,7 +110,7 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
reader := newReader()
for {
fmt.Printf("%s %s", prompt, suffix)
fmt.Fprintf(os.Stderr, "%s %s", prompt, suffix)
var response string
var readErr error
@ -130,7 +130,7 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
readErr = <-errChan
response = strings.ToLower(strings.TrimSpace(response))
case <-time.After(cfg.timeout):
fmt.Println() // New line after timeout
fmt.Fprintln(os.Stderr) // New line after timeout
return cfg.defaultYes
}
} else {
@ -245,9 +245,9 @@ func Question(prompt string, opts ...QuestionOption) string {
for {
// Build prompt with default
if cfg.defaultValue != "" {
fmt.Printf("%s [%s] ", prompt, compileGlyphs(cfg.defaultValue))
fmt.Fprintf(os.Stderr, "%s [%s] ", prompt, compileGlyphs(cfg.defaultValue))
} else {
fmt.Printf("%s ", prompt)
fmt.Fprintf(os.Stderr, "%s ", prompt)
}
response, err := reader.ReadString('\n')
@ -364,9 +364,9 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
renderChoices(prompt, items, visible, cfg.displayFn, cfg.defaultN, cfg.filter)
if cfg.filter {
fmt.Printf("Enter number [1-%d] or filter: ", len(visible))
fmt.Fprintf(os.Stderr, "Enter number [1-%d] or filter: ", len(visible))
} else {
fmt.Printf("Enter number [1-%d]: ", len(visible))
fmt.Fprintf(os.Stderr, "Enter number [1-%d]: ", len(visible))
}
response, err := reader.ReadString('\n')
response = strings.TrimSpace(response)
@ -458,9 +458,9 @@ func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
renderChoices(prompt, items, visible, cfg.displayFn, -1, cfg.filter)
if cfg.filter {
fmt.Printf("Enter numbers (e.g., 1 3 5 or 1-3), or filter text, or empty for none: ")
fmt.Fprint(os.Stderr, "Enter numbers (e.g., 1 3 5 or 1-3), or filter text, or empty for none: ")
} else {
fmt.Printf("Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
fmt.Fprint(os.Stderr, "Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
}
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(response)
@ -503,16 +503,16 @@ func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
}
func renderChoices[T any](prompt string, items []T, visible []int, displayFn func(T) string, defaultN int, filter bool) {
fmt.Println(prompt)
fmt.Fprintln(os.Stderr, prompt)
for i, idx := range visible {
marker := " "
if defaultN >= 0 && idx == defaultN {
marker = "*"
}
fmt.Printf(" %s%d. %s\n", marker, i+1, compileGlyphs(displayFn(items[idx])))
fmt.Fprintf(os.Stderr, " %s%d. %s\n", marker, i+1, compileGlyphs(displayFn(items[idx])))
}
if filter {
fmt.Println(" (type to filter the list)")
fmt.Fprintln(os.Stderr, " (type to filter the list)")
}
}