Skip to content

Commit e2ae2ca

Browse files
committed
Hide animated loading bar in non-TTY environments
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.
1 parent 0009967 commit e2ae2ca

File tree

2 files changed

+116
-102
lines changed

2 files changed

+116
-102
lines changed
Lines changed: 93 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {LoadingBar} from './LoadingBar.js'
2+
import {Stdout} from '../../ui.js'
23
import {render} from '../../testing/ui.js'
34
import {shouldDisplayColors, unstyled} from '../../../../public/node/output.js'
45
import useLayout from '../hooks/use-layout.js'
@@ -25,196 +26,188 @@ beforeEach(() => {
2526
vi.mocked(shouldDisplayColors).mockReturnValue(true)
2627
})
2728

29+
/**
30+
* Creates a Stdout test double simulating a TTY stream (the default for
31+
* interactive terminals). On real Node streams, `isTTY` is only defined
32+
* as an own property when the stream IS a TTY — it's absent otherwise.
33+
*/
34+
function createTTYStdout(columns = 100) {
35+
const stdout = new Stdout({columns}) as Stdout & {isTTY: boolean}
36+
stdout.isTTY = true
37+
return stdout
38+
}
39+
40+
/**
41+
* Creates a Stdout test double simulating a non-TTY environment
42+
* (piped output, CI without a pseudo-TTY, AI coding agents).
43+
*/
44+
function createNonTTYStdout(columns = 100) {
45+
const stdout = new Stdout({columns}) as Stdout & {isTTY: boolean}
46+
stdout.isTTY = false
47+
return stdout
48+
}
49+
50+
/**
51+
* Renders LoadingBar with a TTY stdout and returns the last frame.
52+
* Most tests need a TTY to verify the animated progress bar renders.
53+
*/
54+
function renderWithTTY(element: React.ReactElement) {
55+
const stdout = createTTYStdout()
56+
const instance = render(element, {stdout})
57+
return {lastFrame: stdout.lastFrame, unmount: instance.unmount, stdout}
58+
}
59+
2860
describe('LoadingBar', () => {
2961
test('renders loading bar with default colored characters', async () => {
30-
// Given
31-
const title = 'Loading content'
32-
33-
// When
34-
const {lastFrame} = render(<LoadingBar title={title} />)
62+
const {lastFrame} = renderWithTTY(<LoadingBar title="Loading content" />)
3563

36-
// Then
3764
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
3865
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
3966
Loading content ..."
4067
`)
4168
})
4269

4370
test('renders loading bar with hill pattern when noColor prop is true', async () => {
44-
// Given
45-
const title = 'Processing files'
71+
const {lastFrame} = renderWithTTY(<LoadingBar title="Processing files" noColor />)
4672

47-
// When
48-
const {lastFrame} = render(<LoadingBar title={title} noColor />)
49-
50-
// Then
5173
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
5274
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅
5375
Processing files ..."
5476
`)
5577
})
5678

5779
test('renders loading bar with hill pattern when shouldDisplayColors returns false', async () => {
58-
// Given
5980
vi.mocked(shouldDisplayColors).mockReturnValue(false)
60-
const title = 'Downloading packages'
6181

62-
// When
63-
const {lastFrame} = render(<LoadingBar title={title} />)
82+
const {lastFrame} = renderWithTTY(<LoadingBar title="Downloading packages" />)
6483

65-
// Then
6684
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
6785
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅
6886
Downloading packages ..."
6987
`)
7088
})
7189

7290
test('handles narrow terminal width correctly', async () => {
73-
// Given
74-
vi.mocked(useLayout).mockReturnValue({
75-
twoThirds: 20,
76-
oneThird: 10,
77-
fullWidth: 30,
78-
})
79-
const title = 'Building app'
80-
81-
// When
82-
const {lastFrame} = render(<LoadingBar title={title} />)
83-
84-
// Then
91+
vi.mocked(useLayout).mockReturnValue({twoThirds: 20, oneThird: 10, fullWidth: 30})
92+
93+
const {lastFrame} = renderWithTTY(<LoadingBar title="Building app" />)
94+
8595
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
8696
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
8797
Building app ..."
8898
`)
8999
})
90100

91101
test('handles narrow terminal width correctly in no-color mode', async () => {
92-
// Given
93-
vi.mocked(useLayout).mockReturnValue({
94-
twoThirds: 15,
95-
oneThird: 8,
96-
fullWidth: 23,
97-
})
98-
const title = 'Installing'
99-
100-
// When
101-
const {lastFrame} = render(<LoadingBar title={title} noColor />)
102-
103-
// Then
102+
vi.mocked(useLayout).mockReturnValue({twoThirds: 15, oneThird: 8, fullWidth: 23})
103+
104+
const {lastFrame} = renderWithTTY(<LoadingBar title="Installing" noColor />)
105+
104106
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
105107
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇
106108
Installing ..."
107109
`)
108110
})
109111

110112
test('handles very narrow terminal width in no-color mode', async () => {
111-
// Given
112-
vi.mocked(useLayout).mockReturnValue({
113-
twoThirds: 5,
114-
oneThird: 3,
115-
fullWidth: 8,
116-
})
117-
const title = 'Wait'
118-
119-
// When
120-
const {lastFrame} = render(<LoadingBar title={title} noColor />)
121-
122-
// Then
113+
vi.mocked(useLayout).mockReturnValue({twoThirds: 5, oneThird: 3, fullWidth: 8})
114+
115+
const {lastFrame} = renderWithTTY(<LoadingBar title="Wait" noColor />)
116+
123117
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
124118
"▁▁▁▂▂
125119
Wait ..."
126120
`)
127121
})
128122

129123
test('handles wide terminal width correctly', async () => {
130-
// Given
131-
vi.mocked(useLayout).mockReturnValue({
132-
twoThirds: 100,
133-
oneThird: 50,
134-
fullWidth: 150,
135-
})
136-
const title = 'Synchronizing data'
137-
138-
// When
139-
const {lastFrame} = render(<LoadingBar title={title} />)
140-
141-
// Then
124+
vi.mocked(useLayout).mockReturnValue({twoThirds: 100, oneThird: 50, fullWidth: 150})
125+
126+
const {lastFrame} = renderWithTTY(<LoadingBar title="Synchronizing data" />)
127+
142128
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
143129
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
144130
Synchronizing data ..."
145131
`)
146132
})
147133

148134
test('handles wide terminal width correctly in no-color mode with pattern repetition', async () => {
149-
// Given
150-
vi.mocked(useLayout).mockReturnValue({
151-
twoThirds: 90,
152-
oneThird: 45,
153-
fullWidth: 135,
154-
})
155-
const title = 'Analyzing dependencies'
156-
157-
// When
158-
const {lastFrame} = render(<LoadingBar title={title} noColor />)
159-
160-
// Then
135+
vi.mocked(useLayout).mockReturnValue({twoThirds: 90, oneThird: 45, fullWidth: 135})
136+
137+
const {lastFrame} = renderWithTTY(<LoadingBar title="Analyzing dependencies" noColor />)
138+
161139
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
162140
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁
163141
Analyzing dependencies ..."
164142
`)
165143
})
166144

167145
test('renders correctly with empty title', async () => {
168-
// Given
169-
const title = ''
170-
171-
// When
172-
const {lastFrame} = render(<LoadingBar title={title} />)
146+
const {lastFrame} = renderWithTTY(<LoadingBar title="" />)
173147

174-
// Then
175148
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
176149
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
177150
..."
178151
`)
179152
})
180153

181154
test('noColor prop overrides shouldDisplayColors when both would show colors', async () => {
182-
// Given
183155
vi.mocked(shouldDisplayColors).mockReturnValue(true)
184-
const title = 'Testing override'
185156

186-
// When
187-
const {lastFrame} = render(<LoadingBar title={title} noColor />)
157+
const {lastFrame} = renderWithTTY(<LoadingBar title="Testing override" noColor />)
188158

189-
// Then
190159
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
191160
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅
192161
Testing override ..."
193162
`)
194163
})
195164

196165
test('renders consistently with same props', async () => {
197-
// Given
198-
const title = 'Consistent test'
199-
const props = {title, noColor: false}
166+
const props = {title: 'Consistent test', noColor: false}
200167

201-
// When
202-
const {lastFrame: frame1} = render(<LoadingBar {...props} />)
203-
const {lastFrame: frame2} = render(<LoadingBar {...props} />)
168+
const {lastFrame: frame1} = renderWithTTY(<LoadingBar {...props} />)
169+
const {lastFrame: frame2} = renderWithTTY(<LoadingBar {...props} />)
204170

205-
// Then
206171
expect(frame1()).toBe(frame2())
207172
})
208173

209174
test('hides progress bar when noProgressBar is true', async () => {
210-
// Given
211175
vi.mocked(shouldDisplayColors).mockReturnValue(true)
212-
const title = 'task 1'
213176

214-
// When
215-
const {lastFrame} = render(<LoadingBar title={title} noProgressBar />)
177+
const {lastFrame} = renderWithTTY(<LoadingBar title="task 1" noProgressBar />)
216178

217-
// Then
218179
expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`"task 1 ..."`)
219180
})
181+
182+
test('renders only title text without animated progress bar in non-TTY environments', async () => {
183+
const stdout = createNonTTYStdout()
184+
185+
const renderInstance = render(<LoadingBar title="Installing dependencies" />, {stdout})
186+
187+
expect(unstyled(stdout.lastFrame()!)).toMatchInlineSnapshot(`"Installing dependencies ..."`)
188+
renderInstance.unmount()
189+
})
190+
191+
test('renders only title text in non-TTY even when noColor and noProgressBar are not set', async () => {
192+
const stdout = createNonTTYStdout()
193+
vi.mocked(shouldDisplayColors).mockReturnValue(true)
194+
195+
const renderInstance = render(<LoadingBar title="Generating extension" />, {stdout})
196+
197+
expect(unstyled(stdout.lastFrame()!)).toMatchInlineSnapshot(`"Generating extension ..."`)
198+
renderInstance.unmount()
199+
})
200+
201+
test('keeps animated progress bar when Ink renders to a TTY stream (e.g. renderTasksToStdErr)', async () => {
202+
// renderTasksToStdErr passes process.stderr as Ink's stdout option.
203+
// useStdout() returns that stream, so the TTY check uses the correct stream.
204+
const ttyStream = createTTYStdout()
205+
206+
const renderInstance = render(<LoadingBar title="Uploading theme" />, {stdout: ttyStream})
207+
208+
const frame = unstyled(ttyStream.lastFrame()!)
209+
expect(frame).toContain('▀')
210+
expect(frame).toContain('Uploading theme ...')
211+
renderInstance.unmount()
212+
})
220213
})

packages/cli-kit/src/private/node/ui/components/LoadingBar.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import useLayout from '../hooks/use-layout.js'
33
import {shouldDisplayColors} from '../../../../public/node/output.js'
44
import React from 'react'
55

6-
import {Box, Text} from 'ink'
6+
import {Box, Text, useStdout} from 'ink'
77

88
const loadingBarChar = '▀'
99
const hillString = '▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁'
@@ -14,16 +14,37 @@ interface LoadingBarProps {
1414
noProgressBar?: boolean
1515
}
1616

17+
/**
18+
* Checks whether the stream Ink is rendering to supports cursor movement.
19+
* When it doesn't (piped stdout, dumb terminal, non-TTY CI runner, AI coding
20+
* agents), every animation frame would be appended as a new line instead of
21+
* overwriting the previous one.
22+
*
23+
* We inspect the stdout object Ink is actually using (via `useStdout`) so the
24+
* check stays accurate even when a custom stream is provided through
25+
* `renderOptions` (e.g. `renderTasksToStdErr` passes `process.stderr`).
26+
*
27+
* On real Node streams, `isTTY` is only defined as an own property when the
28+
* stream IS a TTY — it's completely absent otherwise, not set to `false`.
29+
* So we check the value directly: truthy means TTY, falsy/missing means not.
30+
*/
31+
function useOutputSupportsCursor(stdout: NodeJS.WriteStream | Record<string, unknown>): boolean {
32+
return Boolean((stdout as Record<string, unknown>).isTTY)
33+
}
34+
1735
const LoadingBar = ({title, noColor, noProgressBar}: React.PropsWithChildren<LoadingBarProps>) => {
1836
const {twoThirds} = useLayout()
37+
const {stdout} = useStdout()
38+
const supportsCursor = useOutputSupportsCursor(stdout)
39+
1940
let loadingBar = new Array(twoThirds).fill(loadingBarChar).join('')
2041
if (noColor ?? !shouldDisplayColors()) {
2142
loadingBar = hillString.repeat(Math.ceil(twoThirds / hillString.length))
2243
}
2344

2445
return (
2546
<Box flexDirection="column">
26-
{!noProgressBar && <TextAnimation text={loadingBar} maxWidth={twoThirds} />}
47+
{supportsCursor && !noProgressBar && <TextAnimation text={loadingBar} maxWidth={twoThirds} />}
2748
<Text>{title} ...</Text>
2849
</Box>
2950
)

0 commit comments

Comments
 (0)