Skip to content

Render task progress bars to stderr by default#7295

Open
amcaplan wants to merge 3 commits intomainfrom
ac/hide-loading-bar-non-tty
Open

Render task progress bars to stderr by default#7295
amcaplan wants to merge 3 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

Progress bars are status feedback, not program output — they belong on stderr per Unix convention. This change makes renderTasks and renderSingleTask default to rendering on stderr.

The LoadingBar component also now detects whether Ink's output stream is a TTY (via useStdout). When it's not — e.g. AI agents that merge stderr via 2>&1 — it shows only the static task title instead of the animated progress bar.

Only 3 files changed — no per-package wrappers needed. Every command using renderTasks or renderSingleTask gets the fix automatically.

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.     │
╰────────────────────────────────────────────────────────────────────────╯

Note on the theme package

The theme package already has its own renderTasksToStdErr helper that passes a custom stderr stream. That helper can be simplified in a follow-up since renderTasks now defaults to stderr, but the theme callers sometimes pass context.stderr (a custom writable), so they're left as-is for now.

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.
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 5 times, most recently from 7681be7 to c85ebba Compare April 14, 2026 20:56
@amcaplan amcaplan changed the title Render extension generation tasks to stderr to reduce output noise Render task progress bars to stderr by default Apr 14, 2026
@amcaplan amcaplan force-pushed the ac/hide-loading-bar-non-tty branch from c85ebba to add3b9c Compare April 14, 2026 21:00
Progress bars are status feedback, not program output — they belong on
stderr per Unix convention. This change makes renderTasks and
renderSingleTask default to rendering on stderr.

This fixes output flooding in non-TTY environments (AI coding agents,
CI) where Ink's ANSI escape codes can't overwrite previous frames,
causing each animation frame to append as a new line.

The LoadingBar component also now detects whether Ink's output stream
is a TTY (via useStdout). When it's not — 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
- Piped stdout (| cat): stderr still a TTY, animation works
- AI agent (stdout capture): stderr animation, stdout clean
- AI agent (2>&1): stderr non-TTY, static title only
- CI: Ink's built-in isCi already suppresses dynamic rendering
@amcaplan amcaplan force-pushed the ac/hide-loading-bar-non-tty branch from add3b9c to f5e48c5 Compare April 14, 2026 21:00
@amcaplan amcaplan marked this pull request as ready for review April 14, 2026 21:04
@amcaplan amcaplan requested a review from a team as a code owner April 14, 2026 21:04
@github-actions
Copy link
Copy Markdown
Contributor

Differences in type declarations

We detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:

  • Some seemingly private modules might be re-exported through public modules.
  • If the branch is behind main you might see odd diffs, rebase main into this branch.

New type declarations

We found no new type declarations in this PR

Existing type declarations

packages/cli-kit/dist/public/node/ui.d.ts
@@ -328,7 +328,6 @@ interface RenderTasksOptions {
 /**
  * Runs async tasks and displays their progress to the console.
  * @example
- * ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
  * Installing dependencies ...
  */
 export declare function renderTasks<TContext>(tasks: Task<TContext>[], { renderOptions, noProgressBar }?: RenderTasksOptions): Promise<TContext>;
@@ -346,7 +345,6 @@ export interface RenderSingleTaskOptions<T> {
  * @param options.renderOptions - Optional render configuration
  * @returns The result of the task
  * @example
- * ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
  * Loading app ...
  */
 export declare function renderSingleTask<T>({ title, task, onAbort, renderOptions, }: RenderSingleTaskOptions<T>): Promise<T>;

@amcaplan
Copy link
Copy Markdown
Contributor Author

amcaplan commented Apr 14, 2026

OK, I think I have this in order:

  • renderTasks now renders the dynamic output to stderr by default (which it should have done before)
  • The component checks the utilized output stream and determines whether or not to display animation based on that

Hence:

# displays animation
shopify app generate extension --path ~/dev/experiments/lean-supply-app/ --template checkout_ui --name checkout_ui_final_test

# displays animation
shopify app generate extension --path ~/dev/experiments/lean-supply-app/ --template checkout_ui --name checkout_ui_final_test | cat

# doesn't display animation because stderr redirected to stdout which is piped - this is the common AI use case
shopify app generate extension --path ~/dev/experiments/lean-supply-app/ --template checkout_ui --name checkout_ui_final_test 2>&1 | cat

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