on main help sign in
fix(noninteractive): support output redirection (aka, pipes) (#1594)
commit36d471b68228d1abfa68577fdcbb0fa1f20ac56a authorChristian Rocha <christian@rocha.is> dateFri 12 Dec 2025, 12:02 (1 year ago) parentcc08edc
4 files changed+42−33
internal/app/app.go+27−10 internal/cmd/root.go+15−3 internal/cmd/run.go−5 internal/term/term.go−15
internal/app/app.go+27−10
@@ -10,6 +10,7 @@ import (
1010 "io"
1111 "log/slog"
1212 "os"
13+ "strings"
1314 "sync"
1415 "time"
1516
@@ -30,13 +31,13 @@ import (
3031 "github.com/charmbracelet/crush/internal/pubsub"
3132 "github.com/charmbracelet/crush/internal/session"
3233 "github.com/charmbracelet/crush/internal/shell"
33− "github.com/charmbracelet/crush/internal/term"
3434 "github.com/charmbracelet/crush/internal/tui/components/anim"
3535 "github.com/charmbracelet/crush/internal/tui/styles"
3636 "github.com/charmbracelet/crush/internal/update"
3737 "github.com/charmbracelet/crush/internal/version"
3838 "github.com/charmbracelet/x/ansi"
3939 "github.com/charmbracelet/x/exp/charmtone"
40+ "github.com/charmbracelet/x/term"
4041)
4142
4243type App struct {
@@ -61,7 +62,7 @@ type App struct {
6162 cleanupFuncs []func() error
6263}
6364
64−// New initializes a new applcation instance.
65+// New initializes a new application instance.
6566func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
6667 q := db.New(conn)
6768 sessions := session.NewService(q)
@@ -129,15 +130,27 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt
129130 ctx, cancel := context.WithCancel(ctx)
130131 defer cancel()
131132
132− var spinner *format.Spinner
133− if !quiet {
133+ var (
134+ spinner *format.Spinner
135+ stdoutTTY bool
136+ stderrTTY bool
137+ stdinTTY bool
138+ )
139+
140+ if f, ok := output.(*os.File); ok {
141+ stdoutTTY = term.IsTerminal(f.Fd())
142+ }
143+ stderrTTY = term.IsTerminal(os.Stderr.Fd())
144+ stdinTTY = term.IsTerminal(os.Stdin.Fd())
145+
146+ if !quiet && stderrTTY {
134147 t := styles.CurrentTheme()
135148
136149 // Detect background color to set the appropriate color for the
137150 // spinner's 'Generating...' text. Without this, that text would be
138151 // unreadable in light terminals.
139152 hasDarkBG := true
140− if f, ok := output.(*os.File); ok {
153+ if f, ok := output.(*os.File); ok && stdinTTY && stdoutTTY {
141154 hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, f)
142155 }
143156 defaultFG := lipgloss.LightDark(hasDarkBG)(charmtone.Pepper, t.FgBase)
@@ -203,10 +216,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt
203216
204217 messageEvents := app.Messages.Subscribe(ctx)
205218 messageReadBytes := make(map[string]int)
206− supportsProgressBar := term.SupportsProgressBar()
207219
208220 defer func() {
209− if supportsProgressBar {
221+ if stderrTTY {
210222 _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
211223 }
212224
@@ -216,9 +228,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt
216228 }()
217229
218230 for {
219− if supportsProgressBar {
220− // HACK: Reinitialize the terminal progress bar on every iteration so
221− // it doesn't get hidden by the terminal due to inactivity.
231+ if stderrTTY {
232+ // HACK: Reinitialize the terminal progress bar on every iteration
233+ // so it doesn't get hidden by the terminal due to inactivity.
222234 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
223235 }
224236
@@ -248,6 +260,11 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt
248260 }
249261
250262 part := content[readBytes:]
263+ // Trim leading whitespace. Sometimes the LLM includes leading
264+ // formatting and intentation, which we don't want here.
265+ if readBytes == 0 {
266+ part = strings.TrimLeft(part, " \t")
267+ }
251268 fmt.Fprint(output, part)
252269 messageReadBytes[msg.ID] = len(content)
253270 }
internal/cmd/root.go+15−3
@@ -20,7 +20,6 @@ import (
2020 "github.com/charmbracelet/crush/internal/db"
2121 "github.com/charmbracelet/crush/internal/event"
2222 "github.com/charmbracelet/crush/internal/stringext"
23− termutil "github.com/charmbracelet/crush/internal/term"
2423 "github.com/charmbracelet/crush/internal/tui"
2524 "github.com/charmbracelet/crush/internal/version"
2625 "github.com/charmbracelet/fang"
@@ -152,8 +151,20 @@ func Execute() {
152151 }
153152}
154153
154+// supportsProgressBar tries to determine whether the current terminal supports
155+// progress bars by looking into environment variables.
156+func supportsProgressBar() bool {
157+ if !term.IsTerminal(os.Stderr.Fd()) {
158+ return false
159+ }
160+ termProg := os.Getenv("TERM_PROGRAM")
161+ _, isWindowsTerminal := os.LookupEnv("WT_SESSION")
162+
163+ return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
164+}
165+
155166func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
156− if termutil.SupportsProgressBar() {
167+ if supportsProgressBar() {
157168 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
158169 defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
159170 }
@@ -228,7 +239,8 @@ func MaybePrependStdin(prompt string) (string, error) {
228239 if err != nil {
229240 return prompt, err
230241 }
231− if fi.Mode()&os.ModeNamedPipe == 0 {
242+ // Check if stdin is a named pipe ( | ) or regular file ( < ).
243+ if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
232244 return prompt, nil
233245 }
234246 bts, err := io.ReadAll(os.Stdin)
internal/cmd/run.go−5
@@ -58,11 +58,6 @@ crush run --quiet "Generate a README for this project"
5858 return fmt.Errorf("no prompt provided")
5959 }
6060
61− // TODO: Make this work when redirected to something other than stdout.
62− // For example:
63− // crush run "Do something fancy" > output.txt
64− // echo "Do something fancy" | crush run > output.txt
65− //
6661 return app.RunNonInteractive(ctx, os.Stdout, prompt, quiet)
6762 },
6863}
internal/term/term.godeleted−15
@@ -1,15 +0,0 @@
1−package term
2−
3−import (
4− "os"
5− "strings"
6−)
7−
8−// SupportsProgressBar tries to determine whether the current terminal supports
9−// progress bars by looking into environment variables.
10−func SupportsProgressBar() bool {
11− termProg := os.Getenv("TERM_PROGRAM")
12− _, isWindowsTerminal := os.LookupEnv("WT_SESSION")
13−
14− return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
15−}