From 4f7a4c3a20177c7937225645eae097295ca928b8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 11:16:05 +0000 Subject: [PATCH] fix(cli): route interactive ui to stderr Co-Authored-By: Virgil --- pkg/cli/output.go | 6 ++--- pkg/cli/prompt.go | 16 ++++++------ pkg/cli/prompt_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++ pkg/cli/tracker.go | 2 +- pkg/cli/utils.go | 22 ++++++++-------- 5 files changed, 80 insertions(+), 23 deletions(-) diff --git a/pkg/cli/output.go b/pkg/cli/output.go index dcdfe59..025d3b9 100644 --- a/pkg/cli/output.go +++ b/pkg/cli/output.go @@ -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. diff --git a/pkg/cli/prompt.go b/pkg/cli/prompt.go index 49ea0e9..3197227 100644 --- a/pkg/cli/prompt.go +++ b/pkg/cli/prompt.go @@ -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') diff --git a/pkg/cli/prompt_test.go b/pkg/cli/prompt_test.go index 1380bea..98ecf8f 100644 --- a/pkg/cli/prompt_test.go +++ b/pkg/cli/prompt_test.go @@ -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:") +} diff --git a/pkg/cli/tracker.go b/pkg/cli/tracker.go index 83da830..b9bf9a5 100644 --- a/pkg/cli/tracker.go +++ b/pkg/cli/tracker.go @@ -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. diff --git a/pkg/cli/utils.go b/pkg/cli/utils.go index aa5cbd9..2b1a123 100644 --- a/pkg/cli/utils.go +++ b/pkg/cli/utils.go @@ -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)") } } -- 2.45.3