Skip to content

Render extension generation tasks to stderr to reduce output noise#7295

Draft
amcaplan wants to merge 2 commits intomainfrom
ac/hide-loading-bar-non-tty
Draft

Render extension generation tasks to stderr to reduce output noise#7295
amcaplan wants to merge 2 commits intomainfrom
ac/hide-loading-bar-non-tty

Conversation

@amcaplan
Copy link
Copy Markdown
Contributor

@amcaplan amcaplan commented Apr 14, 2026

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:

  1. 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.

  2. LoadingBar non-TTY detection (cli-kit): The LoadingBar component now checks isTTY on the stream Ink is actually rendering to (via useStdout()). When the stream is not a TTY — e.g. AI agents that merge stderr via 2>&1 — it shows only the static task title instead of the animated progress bar.

This handles all cases:

  • Interactive terminal: animated progress bar on stderr ✅
  • | cat: stderr is still a TTY → animated bar visible; stdout is clean ✅
  • AI agent (stdout capture): stderr animation, stdout clean ✅
  • AI agent (2>&1): stderr is non-TTY → static title only, no flooding ✅
  • CI: Ink's built-in isCi already suppresses dynamic rendering ✅

Before (AI agent)

Generating extension ...
▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁
Generating extension ...
▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁
... (hundreds more lines)

After (AI agent)

Generating extension ...
Installing dependencies ...
Update shared type definition ...

╭─ success ──────────────────────────────────────────────────────────────╮
│  Your extension was created in extensions/checkout-ui-final-test.     │
╰────────────────────────────────────────────────────────────────────────╯

Type of change

  • Bug fix (non-breaking change which fixes an issue)

@amcaplan amcaplan requested a review from a team as a code owner April 14, 2026 15:50
Copilot AI review requested due to automatic review settings April 14, 2026 15:50
Copy link
Copy Markdown
Contributor

@craigmichaelmartin craigmichaelmartin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion, but thanks for knocking this out!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be cleaner like this, rather than having an early return.

Suggested change
{(!interactive || !noProgressBar) && <TextAnimation text={loadingBar} maxWidth={twoThirds} />}

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 LoadingBar and 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.

Comment on lines +20 to +30
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>
)
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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'
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +230 to +253
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 ..."`)
})
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +30
if (!interactive) {
return (
<Box flexDirection="column">
<Text>{title} ...</Text>
</Box>
)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we animate safely here at least a simple spinner or dot animation? Wouldelp when piping a command :thinking_face:

Copy link
Copy Markdown
Contributor

@nickwesselman nickwesselman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@amcaplan amcaplan force-pushed the ac/hide-loading-bar-non-tty branch 6 times, most recently from 2129f47 to b4273e8 Compare April 14, 2026 18:18
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.
@amcaplan amcaplan force-pushed the ac/hide-loading-bar-non-tty branch from b4273e8 to e2ae2ca Compare April 14, 2026 18:37
@amcaplan
Copy link
Copy Markdown
Contributor Author

@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.

@amcaplan amcaplan marked this pull request as draft April 14, 2026 18:49
@amcaplan amcaplan changed the title Hide animated loading bar in non-TTY environments Render extension generation tasks to stderr to reduce output noise Apr 14, 2026
@amcaplan amcaplan force-pushed the ac/hide-loading-bar-non-tty branch from b2192e8 to 7aad611 Compare April 14, 2026 20:43
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).
@amcaplan amcaplan force-pushed the ac/hide-loading-bar-non-tty branch from 7aad611 to 29482b4 Compare April 14, 2026 20:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants