Render extension generation tasks to stderr to reduce output noise#7295
Render extension generation tasks to stderr to reduce output noise#7295
Conversation
craigmichaelmartin
left a comment
There was a problem hiding this comment.
Suggestion, but thanks for knocking this out!
There was a problem hiding this comment.
I think it'd be cleaner like this, rather than having an early return.
| {(!interactive || !noProgressBar) && <TextAnimation text={loadingBar} maxWidth={twoThirds} />} |
There was a problem hiding this comment.
Pull request overview
This PR reduces CLI output noise in non-interactive environments by preventing LoadingBar’s animated TextAnimation from rendering when the terminal isn’t interactive, outputting only the static title line instead.
Changes:
- Add an interactivity check to
LoadingBarand skip rendering the animated progress bar when non-interactive. - Add unit tests to validate the non-interactive rendering behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| packages/cli-kit/src/private/node/ui/components/LoadingBar.tsx | Adds non-interactive rendering path that omits TextAnimation to avoid flooding output. |
| packages/cli-kit/src/private/node/ui/components/LoadingBar.test.tsx | Mocks isTerminalInteractive and adds snapshot tests for the non-interactive output. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const interactive = isTerminalInteractive() | ||
|
|
||
| // In non-TTY environments (CI, piped output, AI agents), skip the animated | ||
| // progress bar entirely to avoid flooding the output with animation frames. | ||
| if (!interactive) { | ||
| return ( | ||
| <Box flexDirection="column"> | ||
| <Text>{title} ...</Text> | ||
| </Box> | ||
| ) | ||
| } |
There was a problem hiding this comment.
isTerminalInteractive() checks process.stdout.isTTY, but Ink may be rendering to a different stream via renderOptions.stdout (e.g. renderTasksToStdErr passes process.stderr). In those cases stdout can be non-TTY while the Ink output stream is still interactive, so this change will incorrectly disable the progress bar (regression for renderTasks(..., {renderOptions: {stdout: process.stderr}})). Consider basing the check on Ink’s actual stdout from useStdout() (or plumbing an interactive flag down from the render call) rather than process.stdout.
| import {TextAnimation} from './TextAnimation.js' | ||
| import useLayout from '../hooks/use-layout.js' | ||
| import {shouldDisplayColors} from '../../../../public/node/output.js' | ||
| import {isTerminalInteractive} from '../../../../public/node/context/local.js' |
There was a problem hiding this comment.
Importing isTerminalInteractive from public/node/context/local pulls in a large module (e.g. it imports macaddress, filesystem/system helpers, etc.) just to do a TTY check, which can add avoidable startup overhead for any command that renders LoadingBar. If you keep a helper for this, consider moving the TTY/interactivity check into a small, dependency-free module (and optionally re-exporting it from local) so UI components don’t need to load the whole context module.
| test('renders only title text without animated progress bar in non-TTY environments', async () => { | ||
| // Given | ||
| vi.mocked(isTerminalInteractive).mockReturnValue(false) | ||
| const title = 'Installing dependencies' | ||
|
|
||
| // When | ||
| const {lastFrame} = render(<LoadingBar title={title} />) | ||
|
|
||
| // Then | ||
| expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`"Installing dependencies ..."`) | ||
| }) | ||
|
|
||
| test('renders only title text in non-TTY even when noColor and noProgressBar are not set', async () => { | ||
| // Given | ||
| vi.mocked(isTerminalInteractive).mockReturnValue(false) | ||
| vi.mocked(shouldDisplayColors).mockReturnValue(true) | ||
| const title = 'Generating extension' | ||
|
|
||
| // When | ||
| const {lastFrame} = render(<LoadingBar title={title} />) | ||
|
|
||
| // Then | ||
| expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`"Generating extension ..."`) | ||
| }) |
There was a problem hiding this comment.
The new non-TTY tests only cover the isTerminalInteractive=false branch, but the regression risk here is when Ink renders to a different output stream than process.stdout (e.g. renderOptions.stdout = process.stderr). Adding a test that renders LoadingBar with a custom stdout marked isTTY=true while process.stdout.isTTY=false would help ensure the progress bar stays enabled for stderr-based UIs and only disables when the actual Ink output stream is non-TTY.
| if (!interactive) { | ||
| return ( | ||
| <Box flexDirection="column"> | ||
| <Text>{title} ...</Text> | ||
| </Box> | ||
| ) | ||
| } |
There was a problem hiding this comment.
can we animate safely here at least a simple spinner or dot animation? Wouldelp when piping a command :thinking_face:
There was a problem hiding this comment.
Can we try with just a less intrusive animation? There are "interactive" no-TTY cases (like piping output to jq) where the animation is still useful for letting a user know that something is happening
or maybe we should consider just going to a simpler, no-color animation for everyone?
2129f47 to
b4273e8
Compare
In non-TTY environments (CI, piped output, AI coding agents), the TextAnimation component produces a new frame every ~35ms. Since Ink can't overwrite previous lines without a TTY, every frame gets appended as new output, flooding logs with thousands of animation lines. Use isTerminalInteractive() to detect non-TTY environments and render only the static title text (e.g. 'Loading ...') without the animated progress bar. Interactive TTY behavior is completely unchanged. This affects both Tasks and SingleTask components since they both render through LoadingBar.
b4273e8 to
e2ae2ca
Compare
|
@nickwesselman I'm trying to make it that it checks whether the output stream actually used by Ink (in this case, should be stderr) is TTY or not. If you pipe to jq, that only should pipe stdout. I don't think this PR works yet, so I'm currently debugging, I think the wrong approach was chosen. |
b2192e8 to
7aad611
Compare
Render the loading bar to stderr instead of stdout for extension generation tasks, following the pattern already used by the theme package's renderTasksToStdErr. This keeps the animated progress bar out of stdout, which prevents output flooding in non-TTY environments (CI, AI coding agents) where Ink's ANSI escape codes can't overwrite previous frames. stderr is typically still a TTY, so the animation remains visible to interactive users. Piped commands (e.g. | cat) are also unaffected since only stdout is piped. Reverts the previous approach of suppressing the animation at the LoadingBar component level, which couldn't distinguish between 'pipe to terminal' (animation works fine) and 'output captured as text' (animation floods).
7aad611 to
29482b4
Compare
Problem
The animated loading bar renders to stdout, which causes output flooding in non-TTY environments (AI coding agents, CI) where Ink's ANSI escape codes can't overwrite previous frames. Each animation frame (~every 35ms) gets appended as a new line, producing hundreds of lines of noise for commands like
app generate extension.Solution
Two-layer fix:
renderTasksToStdErr(new utility in the app package): Routes extension generation tasks to stderr instead of stdout, following the pattern already used by the theme package. This keeps the animated progress bar out of stdout, so piped commands and AI agents capturing stdout aren't affected.LoadingBarnon-TTY detection (cli-kit): TheLoadingBarcomponent now checksisTTYon the stream Ink is actually rendering to (viauseStdout()). When the stream is not a TTY — e.g. AI agents that merge stderr via2>&1— it shows only the static task title instead of the animated progress bar.This handles all cases:
| cat: stderr is still a TTY → animated bar visible; stdout is clean ✅2>&1): stderr is non-TTY → static title only, no flooding ✅isCialready suppresses dynamic rendering ✅Before (AI agent)
After (AI agent)
Type of change