on main help sign in
fix(noninteractive): support output redirection (aka, pipes) (#1594)
36d471bChristian Rocha
internal/app/app.go+27−10
@@ -10,6 +10,7 @@ import (
10 "io"
10 "io"
11 "log/slog"
11 "log/slog"
12 "os"
12 "os"
13+ "strings"
13 "sync"
14 "sync"
14 "time"
15 "time"
15
16
@@ -30,13 +31,13 @@ import (
30 "github.com/charmbracelet/crush/internal/pubsub"
31 "github.com/charmbracelet/crush/internal/pubsub"
31 "github.com/charmbracelet/crush/internal/session"
32 "github.com/charmbracelet/crush/internal/session"
32 "github.com/charmbracelet/crush/internal/shell"
33 "github.com/charmbracelet/crush/internal/shell"
33− "github.com/charmbracelet/crush/internal/term"
34 "github.com/charmbracelet/crush/internal/tui/components/anim"
34 "github.com/charmbracelet/crush/internal/tui/components/anim"
35 "github.com/charmbracelet/crush/internal/tui/styles"
35 "github.com/charmbracelet/crush/internal/tui/styles"
36 "github.com/charmbracelet/crush/internal/update"
36 "github.com/charmbracelet/crush/internal/update"
37 "github.com/charmbracelet/crush/internal/version"
37 "github.com/charmbracelet/crush/internal/version"
38 "github.com/charmbracelet/x/ansi"
38 "github.com/charmbracelet/x/ansi"
39 "github.com/charmbracelet/x/exp/charmtone"
39 "github.com/charmbracelet/x/exp/charmtone"
40+ "github.com/charmbracelet/x/term"
40)
41)
41
42
42type App struct {
43type App struct {
@@ -61,7 +62,7 @@ type App struct {
61 cleanupFuncs []func() error
62 cleanupFuncs []func() error
62}
63}
63
64
64−// New initializes a new applcation instance.
65+// New initializes a new application instance.
65func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
66func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
66 q := db.New(conn)
67 q := db.New(conn)
67 sessions := session.NewService(q)
68 sessions := session.NewService(q)
@@ -129,15 +130,27 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt
129 ctx, cancel := context.WithCancel(ctx)
130 ctx, cancel := context.WithCancel(ctx)
130 defer cancel()
131 defer cancel()
131
132
133+ var (
132− var spinner *format.Spinner
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+
133− if !quiet {
146+ if !quiet && stderrTTY {
134 t := styles.CurrentTheme()
147 t := styles.CurrentTheme()
135
148
136 // Detect background color to set the appropriate color for the
149 // Detect background color to set the appropriate color for the
137 // spinner's 'Generating...' text. Without this, that text would be
150 // spinner's 'Generating...' text. Without this, that text would be
138 // unreadable in light terminals.
151 // unreadable in light terminals.
139 hasDarkBG := true
152 hasDarkBG := true
140− if f, ok := output.(*os.File); ok {
153+ if f, ok := output.(*os.File); ok && stdinTTY && stdoutTTY {
141 hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, f)
154 hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, f)
142 }
155 }
143 defaultFG := lipgloss.LightDark(hasDarkBG)(charmtone.Pepper, t.FgBase)
156 defaultFG := lipgloss.LightDark(hasDarkBG)(charmtone.Pepper, t.FgBase)
@@ -203,10 +216,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt
203
216
204 messageEvents := app.Messages.Subscribe(ctx)
217 messageEvents := app.Messages.Subscribe(ctx)
205 messageReadBytes := make(map[string]int)
218 messageReadBytes := make(map[string]int)
206− supportsProgressBar := term.SupportsProgressBar()
207
219
208 defer func() {
220 defer func() {
209− if supportsProgressBar {
221+ if stderrTTY {
210 _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
222 _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
211 }
223 }
212
224
@@ -216,9 +228,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt
216 }()
228 }()
217
229
218 for {
230 for {
219− if supportsProgressBar {
231+ if stderrTTY {
220− // HACK: Reinitialize the terminal progress bar on every iteration so
232+ // HACK: Reinitialize the terminal progress bar on every iteration
221− // it doesn't get hidden by the terminal due to inactivity.
233+ // so it doesn't get hidden by the terminal due to inactivity.
222 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
234 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
223 }
235 }
224
236
@@ -248,6 +260,11 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt
248 }
260 }
249
261
250 part := content[readBytes:]
262 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+ }
251 fmt.Fprint(output, part)
268 fmt.Fprint(output, part)
252 messageReadBytes[msg.ID] = len(content)
269 messageReadBytes[msg.ID] = len(content)
253 }
270 }
internal/cmd/root.go+15−3
@@ -20,7 +20,6 @@ import (
20 "github.com/charmbracelet/crush/internal/db"
20 "github.com/charmbracelet/crush/internal/db"
21 "github.com/charmbracelet/crush/internal/event"
21 "github.com/charmbracelet/crush/internal/event"
22 "github.com/charmbracelet/crush/internal/stringext"
22 "github.com/charmbracelet/crush/internal/stringext"
23− termutil "github.com/charmbracelet/crush/internal/term"
24 "github.com/charmbracelet/crush/internal/tui"
23 "github.com/charmbracelet/crush/internal/tui"
25 "github.com/charmbracelet/crush/internal/version"
24 "github.com/charmbracelet/crush/internal/version"
26 "github.com/charmbracelet/fang"
25 "github.com/charmbracelet/fang"
@@ -152,8 +151,20 @@ func Execute() {
152 }
151 }
153}
152}
154
153
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+
155func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
166func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
156− if termutil.SupportsProgressBar() {
167+ if supportsProgressBar() {
157 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
168 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
158 defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
169 defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
159 }
170 }
@@ -228,7 +239,8 @@ func MaybePrependStdin(prompt string) (string, error) {
228 if err != nil {
239 if err != nil {
229 return prompt, err
240 return prompt, err
230 }
241 }
242+ // Check if stdin is a named pipe ( | ) or regular file ( < ).
231− if fi.Mode()&os.ModeNamedPipe == 0 {
243+ if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
232 return prompt, nil
244 return prompt, nil
233 }
245 }
234 bts, err := io.ReadAll(os.Stdin)
246 bts, err := io.ReadAll(os.Stdin)
internal/cmd/run.go−5
@@ -58,11 +58,6 @@ crush run --quiet "Generate a README for this project"
58 return fmt.Errorf("no prompt provided")
58 return fmt.Errorf("no prompt provided")
59 }
59 }
60
60
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− //
66 return app.RunNonInteractive(ctx, os.Stdout, prompt, quiet)
61 return app.RunNonInteractive(ctx, os.Stdout, prompt, quiet)
67 },
62 },
68}
63}
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−}