From fcf5f9cfd547a41079cc568e84534c79c18c4ec4 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 13:39:56 +0000 Subject: [PATCH] fix(cli): make stdio routing injectable Co-Authored-By: Virgil --- pkg/cli/app.go | 14 ++++----- pkg/cli/daemon.go | 15 ++++++++-- pkg/cli/errors.go | 8 ++--- pkg/cli/frame.go | 4 +-- pkg/cli/io.go | 68 ++++++++++++++++++++++++++++++++++++++++++ pkg/cli/output.go | 37 +++++++++++------------ pkg/cli/output_test.go | 24 +++++++++++++++ pkg/cli/prompt.go | 33 +++++++------------- pkg/cli/prompt_test.go | 14 +++++++++ pkg/cli/render.go | 2 +- pkg/cli/styles.go | 2 +- pkg/cli/tracker.go | 2 +- pkg/cli/tree.go | 2 +- pkg/cli/utils.go | 27 ++++++++--------- 14 files changed, 176 insertions(+), 76 deletions(-) create mode 100644 pkg/cli/io.go diff --git a/pkg/cli/app.go b/pkg/cli/app.go index fbc96c6..5cb68a7 100644 --- a/pkg/cli/app.go +++ b/pkg/cli/app.go @@ -7,9 +7,9 @@ import ( "os" "runtime/debug" + "dappco.re/go/core" "forge.lthn.ai/core/go-i18n" "forge.lthn.ai/core/go-log" - "dappco.re/go/core" "github.com/spf13/cobra" ) @@ -98,8 +98,8 @@ func MainWithLocales(locales []LocaleSource, commands ...CommandSetup) { // Initialise CLI runtime if err := Init(Options{ - AppName: AppName, - Version: SemVer(), + AppName: AppName, + Version: SemVer(), I18nSources: extraFS, }); err != nil { Error(err.Error()) @@ -175,13 +175,13 @@ PowerShell: Run: func(cmd *cobra.Command, args []string) { switch args[0] { case "bash": - _ = cmd.Root().GenBashCompletion(os.Stdout) + _ = cmd.Root().GenBashCompletion(stdoutWriter()) case "zsh": - _ = cmd.Root().GenZshCompletion(os.Stdout) + _ = cmd.Root().GenZshCompletion(stdoutWriter()) case "fish": - _ = cmd.Root().GenFishCompletion(os.Stdout, true) + _ = cmd.Root().GenFishCompletion(stdoutWriter(), true) case "powershell": - _ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) + _ = cmd.Root().GenPowerShellCompletionWithDesc(stdoutWriter()) } }, } diff --git a/pkg/cli/daemon.go b/pkg/cli/daemon.go index d116848..df412d0 100644 --- a/pkg/cli/daemon.go +++ b/pkg/cli/daemon.go @@ -60,7 +60,10 @@ func DetectMode() Mode { // cli.Success("interactive output enabled") // } func IsTTY() bool { - return term.IsTerminal(int(os.Stdout.Fd())) + if f, ok := stdoutWriter().(*os.File); ok { + return term.IsTerminal(int(f.Fd())) + } + return false } // IsStdinTTY returns true if stdin is a terminal. @@ -69,7 +72,10 @@ func IsTTY() bool { // cli.Warn("input is piped") // } func IsStdinTTY() bool { - return term.IsTerminal(int(os.Stdin.Fd())) + if f, ok := stdinReader().(*os.File); ok { + return term.IsTerminal(int(f.Fd())) + } + return false } // IsStderrTTY returns true if stderr is a terminal. @@ -78,5 +84,8 @@ func IsStdinTTY() bool { // cli.Progress("load", 1, 3, "config") // } func IsStderrTTY() bool { - return term.IsTerminal(int(os.Stderr.Fd())) + if f, ok := stderrWriter().(*os.File); ok { + return term.IsTerminal(int(f.Fd())) + } + return false } diff --git a/pkg/cli/errors.go b/pkg/cli/errors.go index 0c3cd1f..57fbe92 100644 --- a/pkg/cli/errors.go +++ b/pkg/cli/errors.go @@ -120,7 +120,7 @@ func Exit(code int, err error) error { func Fatal(err error) { if err != nil { LogError("Fatal error", "err", err) - fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+err.Error())) + fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+err.Error())) os.Exit(1) } } @@ -131,7 +131,7 @@ func Fatal(err error) { func Fatalf(format string, args ...any) { msg := fmt.Sprintf(format, args...) LogError("Fatal error", "msg", msg) - fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg)) + fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+msg)) os.Exit(1) } @@ -147,7 +147,7 @@ func FatalWrap(err error, msg string) { } LogError("Fatal error", "msg", msg, "err", err) fullMsg := fmt.Sprintf("%s: %v", msg, err) - fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg)) + fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg)) os.Exit(1) } @@ -164,6 +164,6 @@ func FatalWrapVerb(err error, verb, subject string) { msg := i18n.ActionFailed(verb, subject) LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject) fullMsg := fmt.Sprintf("%s: %v", msg, err) - fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg)) + fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg)) os.Exit(1) } diff --git a/pkg/cli/frame.go b/pkg/cli/frame.go index 7219a78..a89c80c 100644 --- a/pkg/cli/frame.go +++ b/pkg/cli/frame.go @@ -61,7 +61,7 @@ func NewFrame(variant string) *Frame { variant: variant, layout: Layout(variant), models: make(map[Region]Model), - out: os.Stderr, + out: stderrWriter(), done: make(chan struct{}), focused: RegionContent, keyMap: DefaultKeyMap(), @@ -467,7 +467,7 @@ func (f *Frame) runLive() { opts := []tea.ProgramOption{ tea.WithAltScreen(), } - if f.out != os.Stdout { + if f.out != stdoutWriter() { opts = append(opts, tea.WithOutput(f.out)) } diff --git a/pkg/cli/io.go b/pkg/cli/io.go new file mode 100644 index 0000000..217b12c --- /dev/null +++ b/pkg/cli/io.go @@ -0,0 +1,68 @@ +package cli + +import ( + "io" + "os" + "sync" +) + +var ( + stdin io.Reader = os.Stdin + + stdoutOverride io.Writer + stderrOverride io.Writer + + ioMu sync.RWMutex +) + +// SetStdin overrides the default stdin reader for testing. +// Pass nil to restore the real os.Stdin reader. +func SetStdin(r io.Reader) { + ioMu.Lock() + defer ioMu.Unlock() + if r == nil { + stdin = os.Stdin + return + } + stdin = r +} + +// SetStdout overrides the default stdout writer. +// Pass nil to restore writes to os.Stdout. +func SetStdout(w io.Writer) { + ioMu.Lock() + defer ioMu.Unlock() + stdoutOverride = w +} + +// SetStderr overrides the default stderr writer. +// Pass nil to restore writes to os.Stderr. +func SetStderr(w io.Writer) { + ioMu.Lock() + defer ioMu.Unlock() + stderrOverride = w +} + +func stdinReader() io.Reader { + ioMu.RLock() + defer ioMu.RUnlock() + return stdin +} + +func stdoutWriter() io.Writer { + ioMu.RLock() + defer ioMu.RUnlock() + if stdoutOverride != nil { + return stdoutOverride + } + return os.Stdout +} + +func stderrWriter() io.Writer { + ioMu.RLock() + defer ioMu.RUnlock() + if stderrOverride != nil { + return stderrOverride + } + return os.Stderr +} diff --git a/pkg/cli/output.go b/pkg/cli/output.go index fb246b8..54b4479 100644 --- a/pkg/cli/output.go +++ b/pkg/cli/output.go @@ -2,7 +2,6 @@ package cli import ( "fmt" - "os" "strings" "forge.lthn.ai/core/go-i18n" @@ -10,35 +9,35 @@ import ( // Blank prints an empty line. func Blank() { - fmt.Println() + fmt.Fprintln(stdoutWriter()) } // Echo translates a key via i18n.T and prints with newline. // No automatic styling - use Success/Error/Warn/Info for styled output. func Echo(key string, args ...any) { - fmt.Println(compileGlyphs(i18n.T(key, args...))) + fmt.Fprintln(stdoutWriter(), compileGlyphs(i18n.T(key, args...))) } // Print outputs formatted text (no newline). // Glyph shortcodes like :check: are converted. func Print(format string, args ...any) { - fmt.Print(compileGlyphs(fmt.Sprintf(format, args...))) + fmt.Fprint(stdoutWriter(), compileGlyphs(fmt.Sprintf(format, args...))) } // Println outputs formatted text with newline. // Glyph shortcodes like :check: are converted. func Println(format string, args ...any) { - fmt.Println(compileGlyphs(fmt.Sprintf(format, args...))) + fmt.Fprintln(stdoutWriter(), compileGlyphs(fmt.Sprintf(format, args...))) } // Text prints arguments like fmt.Println, but handling glyphs. func Text(args ...any) { - fmt.Println(compileGlyphs(fmt.Sprint(args...))) + fmt.Fprintln(stdoutWriter(), compileGlyphs(fmt.Sprint(args...))) } // Success prints a success message with checkmark (green). func Success(msg string) { - fmt.Println(SuccessStyle.Render(Glyph(":check:") + " " + compileGlyphs(msg))) + fmt.Fprintln(stdoutWriter(), SuccessStyle.Render(Glyph(":check:")+" "+compileGlyphs(msg))) } // Successf prints a formatted success message. @@ -49,7 +48,7 @@ func Successf(format string, args ...any) { // Error prints an error message with cross (red) to stderr and logs it. func Error(msg string) { LogError(msg) - fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+compileGlyphs(msg))) + fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+compileGlyphs(msg))) } // Errorf prints a formatted error message to stderr and logs it. @@ -86,7 +85,7 @@ func ErrorWrapAction(err error, verb string) { // Warn prints a warning message with warning symbol (amber) to stderr and logs it. func Warn(msg string) { LogWarn(msg) - fmt.Fprintln(os.Stderr, WarningStyle.Render(Glyph(":warn:")+" "+compileGlyphs(msg))) + fmt.Fprintln(stderrWriter(), WarningStyle.Render(Glyph(":warn:")+" "+compileGlyphs(msg))) } // Warnf prints a formatted warning message to stderr and logs it. @@ -96,7 +95,7 @@ func Warnf(format string, args ...any) { // Info prints an info message with info symbol (blue). func Info(msg string) { - fmt.Println(InfoStyle.Render(Glyph(":info:") + " " + compileGlyphs(msg))) + fmt.Fprintln(stdoutWriter(), InfoStyle.Render(Glyph(":info:")+" "+compileGlyphs(msg))) } // Infof prints a formatted info message. @@ -106,7 +105,7 @@ func Infof(format string, args ...any) { // Dim prints dimmed text. func Dim(msg string) { - fmt.Println(DimStyle.Render(compileGlyphs(msg))) + fmt.Fprintln(stdoutWriter(), DimStyle.Render(compileGlyphs(msg))) } // Progress prints a progress indicator that overwrites the current line. @@ -114,20 +113,20 @@ func Dim(msg string) { func Progress(verb string, current, total int, item ...string) { msg := compileGlyphs(i18n.Progress(verb)) if len(item) > 0 && item[0] != "" { - fmt.Fprintf(os.Stderr, "\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, compileGlyphs(item[0])) + fmt.Fprintf(stderrWriter(), "\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, compileGlyphs(item[0])) } else { - fmt.Fprintf(os.Stderr, "\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total) + fmt.Fprintf(stderrWriter(), "\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total) } } // ProgressDone clears the progress line. func ProgressDone() { - fmt.Fprint(os.Stderr, "\033[2K\r") + fmt.Fprint(stderrWriter(), "\033[2K\r") } // Label prints a "Label: value" line. func Label(word, value string) { - fmt.Printf("%s %s\n", KeyStyle.Render(compileGlyphs(i18n.Label(word))), compileGlyphs(value)) + fmt.Fprintf(stdoutWriter(), "%s %s\n", KeyStyle.Render(compileGlyphs(i18n.Label(word))), compileGlyphs(value)) } // Scanln reads from stdin. @@ -140,7 +139,7 @@ func Scanln(a ...any) (int, error) { // cli.Task("php", "Running tests...") // [php] Running tests... // cli.Task("go", i18n.Progress("build")) // [go] Building... func Task(label, message string) { - fmt.Printf("%s %s\n\n", DimStyle.Render("["+compileGlyphs(label)+"]"), compileGlyphs(message)) + fmt.Fprintf(stdoutWriter(), "%s %s\n\n", DimStyle.Render("["+compileGlyphs(label)+"]"), compileGlyphs(message)) } // Section prints a section header: "── SECTION ──" @@ -149,7 +148,7 @@ func Task(label, message string) { func Section(name string) { dash := Glyph(":dash:") header := dash + dash + " " + strings.ToUpper(compileGlyphs(name)) + " " + dash + dash - fmt.Println(AccentStyle.Render(header)) + fmt.Fprintln(stdoutWriter(), AccentStyle.Render(header)) } // Hint prints a labelled hint: "label: message" @@ -157,7 +156,7 @@ func Section(name string) { // cli.Hint("install", "composer require vimeo/psalm") // cli.Hint("fix", "core php fmt --fix") func Hint(label, message string) { - fmt.Printf(" %s %s\n", DimStyle.Render(compileGlyphs(label)+":"), compileGlyphs(message)) + fmt.Fprintf(stdoutWriter(), " %s %s\n", DimStyle.Render(compileGlyphs(label)+":"), compileGlyphs(message)) } // Severity prints a severity-styled message. @@ -180,7 +179,7 @@ func Severity(level, message string) { default: style = DimStyle } - fmt.Printf(" %s %s\n", style.Render("["+compileGlyphs(level)+"]"), compileGlyphs(message)) + fmt.Fprintf(stdoutWriter(), " %s %s\n", style.Render("["+compileGlyphs(level)+"]"), compileGlyphs(message)) } // Result prints a result line: "✓ message" or "✗ message" diff --git a/pkg/cli/output_test.go b/pkg/cli/output_test.go index 47d4f5a..7ac5e0e 100644 --- a/pkg/cli/output_test.go +++ b/pkg/cli/output_test.go @@ -159,3 +159,27 @@ func TestScanln_UsesOverrideStdin(t *testing.T) { t.Fatalf("expected %q, got %q", "hello", got) } } + +func TestOutputSetters_Good(t *testing.T) { + var out bytes.Buffer + var err bytes.Buffer + + SetStdout(&out) + SetStderr(&err) + t.Cleanup(func() { + SetStdout(nil) + SetStderr(nil) + }) + + Success("done") + Error("fail") + Info("note") + Warn("careful") + + if out.Len() == 0 { + t.Fatal("expected stdout writer to receive output") + } + if err.Len() == 0 { + t.Fatal("expected stderr writer to receive output") + } +} diff --git a/pkg/cli/prompt.go b/pkg/cli/prompt.go index bc90caf..867b053 100644 --- a/pkg/cli/prompt.go +++ b/pkg/cli/prompt.go @@ -5,29 +5,16 @@ import ( "errors" "fmt" "io" - "os" "strconv" "strings" ) -var stdin io.Reader = os.Stdin - -// SetStdin overrides the default stdin reader for testing. -// Pass nil to restore the real os.Stdin reader. -func SetStdin(r io.Reader) { - if r == nil { - stdin = os.Stdin - return - } - stdin = r -} - // newReader wraps stdin in a bufio.Reader if it isn't one already. func newReader() *bufio.Reader { - if br, ok := stdin.(*bufio.Reader); ok { + if br, ok := stdinReader().(*bufio.Reader); ok { return br } - return bufio.NewReader(stdin) + return bufio.NewReader(stdinReader()) } // Prompt asks for text input with a default value. @@ -35,9 +22,9 @@ func Prompt(label, defaultVal string) (string, error) { label = compileGlyphs(label) defaultVal = compileGlyphs(defaultVal) if defaultVal != "" { - fmt.Fprintf(os.Stderr, "%s [%s]: ", label, defaultVal) + fmt.Fprintf(stderrWriter(), "%s [%s]: ", label, defaultVal) } else { - fmt.Fprintf(os.Stderr, "%s: ", label) + fmt.Fprintf(stderrWriter(), "%s: ", label) } r := newReader() @@ -66,11 +53,11 @@ func Select(label string, options []string) (string, error) { return "", nil } - fmt.Fprintln(os.Stderr, compileGlyphs(label)) + fmt.Fprintln(stderrWriter(), compileGlyphs(label)) for i, opt := range options { - fmt.Fprintf(os.Stderr, " %d. %s\n", i+1, compileGlyphs(opt)) + fmt.Fprintf(stderrWriter(), " %d. %s\n", i+1, compileGlyphs(opt)) } - fmt.Fprintf(os.Stderr, "Choose [1-%d]: ", len(options)) + fmt.Fprintf(stderrWriter(), "Choose [1-%d]: ", len(options)) r := newReader() input, err := r.ReadString('\n') @@ -94,11 +81,11 @@ func MultiSelect(label string, options []string) ([]string, error) { return []string{}, nil } - fmt.Fprintln(os.Stderr, compileGlyphs(label)) + fmt.Fprintln(stderrWriter(), compileGlyphs(label)) for i, opt := range options { - fmt.Fprintf(os.Stderr, " %d. %s\n", i+1, compileGlyphs(opt)) + fmt.Fprintf(stderrWriter(), " %d. %s\n", i+1, compileGlyphs(opt)) } - fmt.Fprintf(os.Stderr, "Choose (space-separated) [1-%d]: ", len(options)) + fmt.Fprintf(stderrWriter(), "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 a5d2ddf..795072a 100644 --- a/pkg/cli/prompt_test.go +++ b/pkg/cli/prompt_test.go @@ -395,6 +395,20 @@ func TestSetStdin_Good_ResetNil(t *testing.T) { assert.Same(t, os.Stdin, stdin) } +func TestPrompt_Good_UsesStderrSetter(t *testing.T) { + SetStdin(strings.NewReader("alice\n")) + defer SetStdin(nil) + + var errBuf bytes.Buffer + SetStderr(&errBuf) + defer SetStderr(nil) + + val, err := Prompt("Name", "") + assert.NoError(t, err) + assert.Equal(t, "alice", val) + assert.Contains(t, errBuf.String(), "Name") +} + func TestPromptHints_Good_UseStderr(t *testing.T) { oldOut := os.Stdout oldErr := os.Stderr diff --git a/pkg/cli/render.go b/pkg/cli/render.go index 7075d89..42f14ea 100644 --- a/pkg/cli/render.go +++ b/pkg/cli/render.go @@ -41,7 +41,7 @@ func UseRenderBoxed() { currentRenderStyle = RenderBoxed } // Render outputs the layout to terminal. func (c *Composite) Render() { - fmt.Print(c.String()) + fmt.Fprint(stdoutWriter(), c.String()) } // String returns the rendered layout. diff --git a/pkg/cli/styles.go b/pkg/cli/styles.go index 921018a..8a9aa7f 100644 --- a/pkg/cli/styles.go +++ b/pkg/cli/styles.go @@ -275,7 +275,7 @@ func (t *Table) String() string { // Render prints the table to stdout. func (t *Table) Render() { - fmt.Print(t.String()) + fmt.Fprint(stdoutWriter(), t.String()) } func (t *Table) colCount() int { diff --git a/pkg/cli/tracker.go b/pkg/cli/tracker.go index d92ee31..1a6bd99 100644 --- a/pkg/cli/tracker.go +++ b/pkg/cli/tracker.go @@ -120,7 +120,7 @@ func (tr *TaskTracker) Snapshots() iter.Seq2[string, string] { // NewTaskTracker creates a new parallel task tracker. func NewTaskTracker() *TaskTracker { - return &TaskTracker{out: os.Stderr} + return &TaskTracker{out: stderrWriter()} } // WithOutput sets the destination writer for tracker output. diff --git a/pkg/cli/tree.go b/pkg/cli/tree.go index d93a5c1..21a4a6f 100644 --- a/pkg/cli/tree.go +++ b/pkg/cli/tree.go @@ -79,7 +79,7 @@ func (n *TreeNode) String() string { // Render prints the tree to stdout. func (n *TreeNode) Render() { - fmt.Print(n.String()) + fmt.Fprint(stdoutWriter(), n.String()) } func (n *TreeNode) renderLabel() string { diff --git a/pkg/cli/utils.go b/pkg/cli/utils.go index 0524fc2..404fba7 100644 --- a/pkg/cli/utils.go +++ b/pkg/cli/utils.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os" "os/exec" "strings" "time" @@ -44,11 +43,11 @@ type confirmConfig struct { } func promptHint(msg string) { - fmt.Fprintln(os.Stderr, DimStyle.Render(compileGlyphs(msg))) + fmt.Fprintln(stderrWriter(), DimStyle.Render(compileGlyphs(msg))) } func promptWarning(msg string) { - fmt.Fprintln(os.Stderr, WarningStyle.Render(compileGlyphs(msg))) + fmt.Fprintln(stderrWriter(), WarningStyle.Render(compileGlyphs(msg))) } // DefaultYes sets the default response to "yes" (pressing Enter confirms). @@ -114,7 +113,7 @@ func Confirm(prompt string, opts ...ConfirmOption) bool { reader := newReader() for { - fmt.Fprintf(os.Stderr, "%s %s", prompt, suffix) + fmt.Fprintf(stderrWriter(), "%s %s", prompt, suffix) var response string var readErr error @@ -134,7 +133,7 @@ func Confirm(prompt string, opts ...ConfirmOption) bool { readErr = <-errChan response = strings.ToLower(strings.TrimSpace(response)) case <-time.After(cfg.timeout): - fmt.Fprintln(os.Stderr) // New line after timeout + fmt.Fprintln(stderrWriter()) // New line after timeout return cfg.defaultYes } } else { @@ -251,9 +250,9 @@ func Question(prompt string, opts ...QuestionOption) string { for { // Build prompt with default if cfg.defaultValue != "" { - fmt.Fprintf(os.Stderr, "%s [%s] ", prompt, compileGlyphs(cfg.defaultValue)) + fmt.Fprintf(stderrWriter(), "%s [%s] ", prompt, compileGlyphs(cfg.defaultValue)) } else { - fmt.Fprintf(os.Stderr, "%s ", prompt) + fmt.Fprintf(stderrWriter(), "%s ", prompt) } response, err := reader.ReadString('\n') @@ -375,9 +374,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.Fprintf(os.Stderr, "Enter number [1-%d] or filter: ", len(visible)) + fmt.Fprintf(stderrWriter(), "Enter number [1-%d] or filter: ", len(visible)) } else { - fmt.Fprintf(os.Stderr, "Enter number [1-%d]: ", len(visible)) + fmt.Fprintf(stderrWriter(), "Enter number [1-%d]: ", len(visible)) } response, err := reader.ReadString('\n') response = strings.TrimSpace(response) @@ -474,9 +473,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.Fprint(os.Stderr, "Enter numbers (e.g., 1 3 5 or 1-3), or filter text, or empty for none: ") + fmt.Fprint(stderrWriter(), "Enter numbers (e.g., 1 3 5 or 1-3), or filter text, or empty for none: ") } else { - fmt.Fprint(os.Stderr, "Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ") + fmt.Fprint(stderrWriter(), "Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ") } response, _ := reader.ReadString('\n') response = strings.TrimSpace(response) @@ -512,16 +511,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.Fprintln(os.Stderr, prompt) + fmt.Fprintln(stderrWriter(), prompt) for i, idx := range visible { marker := " " if defaultN >= 0 && idx == defaultN { marker = "*" } - fmt.Fprintf(os.Stderr, " %s%d. %s\n", marker, i+1, compileGlyphs(displayFn(items[idx]))) + fmt.Fprintf(stderrWriter(), " %s%d. %s\n", marker, i+1, compileGlyphs(displayFn(items[idx]))) } if filter { - fmt.Fprintln(os.Stderr, " (type to filter the list)") + fmt.Fprintln(stderrWriter(), " (type to filter the list)") } } -- 2.45.3