From 36d471b68228d1abfa68577fdcbb0fa1f20ac56a Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 12 Dec 2025 12:02:30 -0500 Subject: [PATCH] fix(noninteractive): support output redirection (aka, pipes) (#1594) --- internal/app/app.go | 37 +++++++++++++++++++++++++++---------- internal/cmd/root.go | 18 +++++++++++++++--- internal/cmd/run.go | 5 ----- internal/term/term.go | 15 --------------- 4 files changed, 42 insertions(+), 33 deletions(-) delete mode 100644 internal/term/term.go diff --git a/internal/app/app.go b/internal/app/app.go index 1694a0ecb39266cdd4676a346cfdeaa2be47579a..7e64eaf7bbd7fbc6d32935a26b284128ffb5445f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -10,6 +10,7 @@ import ( "io" "log/slog" "os" + "strings" "sync" "time" @@ -30,13 +31,13 @@ import ( "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/shell" - "github.com/charmbracelet/crush/internal/term" "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/update" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/charmtone" + "github.com/charmbracelet/x/term" ) type App struct { @@ -61,7 +62,7 @@ type App struct { cleanupFuncs []func() error } -// New initializes a new applcation instance. +// New initializes a new application instance. func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { q := db.New(conn) sessions := session.NewService(q) @@ -129,15 +130,27 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt ctx, cancel := context.WithCancel(ctx) defer cancel() - var spinner *format.Spinner - if !quiet { + var ( + spinner *format.Spinner + stdoutTTY bool + stderrTTY bool + stdinTTY bool + ) + + if f, ok := output.(*os.File); ok { + stdoutTTY = term.IsTerminal(f.Fd()) + } + stderrTTY = term.IsTerminal(os.Stderr.Fd()) + stdinTTY = term.IsTerminal(os.Stdin.Fd()) + + if !quiet && stderrTTY { t := styles.CurrentTheme() // Detect background color to set the appropriate color for the // spinner's 'Generating...' text. Without this, that text would be // unreadable in light terminals. hasDarkBG := true - if f, ok := output.(*os.File); ok { + if f, ok := output.(*os.File); ok && stdinTTY && stdoutTTY { hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, f) } defaultFG := lipgloss.LightDark(hasDarkBG)(charmtone.Pepper, t.FgBase) @@ -203,10 +216,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt messageEvents := app.Messages.Subscribe(ctx) messageReadBytes := make(map[string]int) - supportsProgressBar := term.SupportsProgressBar() defer func() { - if supportsProgressBar { + if stderrTTY { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) } @@ -216,9 +228,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt }() for { - if supportsProgressBar { - // HACK: Reinitialize the terminal progress bar on every iteration so - // it doesn't get hidden by the terminal due to inactivity. + if stderrTTY { + // HACK: Reinitialize the terminal progress bar on every iteration + // so it doesn't get hidden by the terminal due to inactivity. _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar) } @@ -248,6 +260,11 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt } part := content[readBytes:] + // Trim leading whitespace. Sometimes the LLM includes leading + // formatting and intentation, which we don't want here. + if readBytes == 0 { + part = strings.TrimLeft(part, " \t") + } fmt.Fprint(output, part) messageReadBytes[msg.ID] = len(content) } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 17c23aeabfd6aaca438d30a0d59cf014c3134f4b..f12b869dc772679a39ef1c306e20e77a91038a8c 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -20,7 +20,6 @@ import ( "github.com/charmbracelet/crush/internal/db" "github.com/charmbracelet/crush/internal/event" "github.com/charmbracelet/crush/internal/stringext" - termutil "github.com/charmbracelet/crush/internal/term" "github.com/charmbracelet/crush/internal/tui" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/fang" @@ -152,8 +151,20 @@ func Execute() { } } +// supportsProgressBar tries to determine whether the current terminal supports +// progress bars by looking into environment variables. +func supportsProgressBar() bool { + if !term.IsTerminal(os.Stderr.Fd()) { + return false + } + termProg := os.Getenv("TERM_PROGRAM") + _, isWindowsTerminal := os.LookupEnv("WT_SESSION") + + return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty") +} + func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) { - if termutil.SupportsProgressBar() { + if supportsProgressBar() { _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar) defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }() } @@ -228,7 +239,8 @@ func MaybePrependStdin(prompt string) (string, error) { if err != nil { return prompt, err } - if fi.Mode()&os.ModeNamedPipe == 0 { + // Check if stdin is a named pipe ( | ) or regular file ( < ). + if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() { return prompt, nil } bts, err := io.ReadAll(os.Stdin) diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 3cebabb1c78a356c55636ec11f1645db072c6e54..76e74a686f5d68be2090ba2ea15bd3049e173b25 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -58,11 +58,6 @@ crush run --quiet "Generate a README for this project" return fmt.Errorf("no prompt provided") } - // TODO: Make this work when redirected to something other than stdout. - // For example: - // crush run "Do something fancy" > output.txt - // echo "Do something fancy" | crush run > output.txt - // return app.RunNonInteractive(ctx, os.Stdout, prompt, quiet) }, } diff --git a/internal/term/term.go b/internal/term/term.go deleted file mode 100644 index 8f86ea241a226c6db389091ca0683cab7f0ac436..0000000000000000000000000000000000000000 --- a/internal/term/term.go +++ /dev/null @@ -1,15 +0,0 @@ -package term - -import ( - "os" - "strings" -) - -// SupportsProgressBar tries to determine whether the current terminal supports -// progress bars by looking into environment variables. -func SupportsProgressBar() bool { - termProg := os.Getenv("TERM_PROGRAM") - _, isWindowsTerminal := os.LookupEnv("WT_SESSION") - - return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty") -}