@@ -18,18 +18,16 @@ import util from 'node:util';
1818import throttle from 'throttleit' ;
1919
2020type UnderlyingStream = NodeJS . WritableStream ;
21+ type StreamChunk = Buffer | Uint8Array | string ;
2122
2223const moveCursor = util . promisify ( readline . moveCursor ) ;
2324const clearScreenDown = util . promisify ( readline . clearScreenDown ) ;
24- const streamWrite = util . promisify (
25- (
26- stream : UnderlyingStream ,
27- chunk : Buffer | Uint8Array | string ,
28- callback ?: ( data : any ) => void
29- ) => {
30- return stream . write ( chunk , callback ) ;
31- }
32- ) ;
25+ type WriteCallback = ( error ?: Error | null ) => void ;
26+ type ExternalWrite = {
27+ chunk : StreamChunk ;
28+ encoding ?: BufferEncoding ;
29+ callback ?: WriteCallback ;
30+ } ;
3331
3432/**
3533 * Cut a string into an array of string of the specific maximum size. A newline
@@ -94,8 +92,16 @@ class Terminal {
9492 _statusStr : string ;
9593 _stream : UnderlyingStream ;
9694 _ttyStream : tty . WriteStream | null ;
95+ // Bound reference to the original stream.write. We keep this so our
96+ // interception layer can still delegate to the real writer.
97+ _rawStreamWrite : ( ...args : Array < any > ) => boolean ;
98+ // Writes performed outside Terminal.log/status while a status is visible.
99+ // We replay them in _update() so they are not erased by status redraws.
100+ _externalWrites : Array < ExternalWrite > ;
97101 _updatePromise : Promise < void > | null ;
98102 _isUpdating : boolean ;
103+ // Guards our own cursor/status writes from being treated as "external".
104+ _isInternalWrite : boolean ;
99105 _isPendingUpdate : boolean ;
100106 _shouldFlush : boolean ;
101107 _writeStatusThrottled : ( status : string ) => void ;
@@ -109,14 +115,117 @@ class Terminal {
109115 this . _statusStr = '' ;
110116 this . _stream = stream ;
111117 this . _ttyStream = ttyPrint ? getTTYStream ( stream ) : null ;
118+ this . _rawStreamWrite = stream . write . bind ( stream ) as (
119+ ...args : Array < any >
120+ ) => boolean ;
121+ this . _externalWrites = [ ] ;
112122 this . _updatePromise = null ;
113123 this . _isUpdating = false ;
124+ this . _isInternalWrite = false ;
114125 this . _isPendingUpdate = false ;
115126 this . _shouldFlush = false ;
116- this . _writeStatusThrottled = throttle (
117- ( status ) => this . _stream . write ( status ) ,
118- 3500
119- ) ;
127+ this . _writeStatusThrottled = throttle ( ( status ) => {
128+ this . _writeRaw ( status ) ;
129+ } , 3500 ) ;
130+
131+ this . _patchTTYStreamWrites ( ) ;
132+ }
133+
134+ _patchTTYStreamWrites ( ) : void {
135+ if ( ! this . _ttyStream ) {
136+ return ;
137+ }
138+
139+ // In interactive TTY mode, status redraw uses cursor movement + clear.
140+ // Any direct stream.write from other sources (plugins, other loggers)
141+ // can be wiped by that redraw. Intercept those writes and route them
142+ // through _update() so they are persisted above the status line.
143+ this . _stream . write = ( (
144+ chunk : StreamChunk ,
145+ encodingOrCallback ?: BufferEncoding | WriteCallback ,
146+ maybeCallback ?: WriteCallback
147+ ) => {
148+ const encoding =
149+ typeof encodingOrCallback === 'string' ? encodingOrCallback : undefined ;
150+ const callback =
151+ typeof encodingOrCallback === 'function'
152+ ? encodingOrCallback
153+ : maybeCallback ;
154+
155+ const shouldCaptureExternalWrite =
156+ ! this . _isInternalWrite && this . _hasVisibleStatus ( ) ;
157+
158+ if ( ! shouldCaptureExternalWrite ) {
159+ return this . _writeRaw ( chunk , encoding , callback ) ;
160+ }
161+
162+ // Queue for replay in _update() before status is drawn again.
163+ this . _externalWrites . push ( { chunk, encoding, callback } ) ;
164+ this . _scheduleUpdate ( ) ;
165+ return true ;
166+ } ) as UnderlyingStream [ 'write' ] ;
167+ }
168+
169+ _writeRaw (
170+ chunk : StreamChunk ,
171+ encoding ?: BufferEncoding ,
172+ callback ?: WriteCallback
173+ ) : boolean {
174+ if ( encoding !== undefined ) {
175+ return this . _rawStreamWrite ( chunk , encoding , callback ) ;
176+ }
177+
178+ if ( callback ) {
179+ return this . _rawStreamWrite ( chunk , callback ) ;
180+ }
181+
182+ return this . _rawStreamWrite ( chunk ) ;
183+ }
184+
185+ async _writeInternal (
186+ chunk : StreamChunk ,
187+ encoding ?: BufferEncoding
188+ ) : Promise < void > {
189+ // Wrap stream writes in a promise so _update() can preserve ordering:
190+ // clear old status -> logs/external writes -> new status.
191+ await new Promise < void > ( ( resolve , reject ) => {
192+ this . _isInternalWrite = true ;
193+
194+ const done : WriteCallback = ( error ) => {
195+ this . _isInternalWrite = false ;
196+ if ( error ) {
197+ reject ( error ) ;
198+ return ;
199+ }
200+ resolve ( ) ;
201+ } ;
202+
203+ try {
204+ this . _writeRaw ( chunk , encoding , done ) ;
205+ } catch ( error ) {
206+ this . _isInternalWrite = false ;
207+ reject ( error as Error ) ;
208+ }
209+ } ) ;
210+ }
211+
212+ _hasVisibleStatus ( ) : boolean {
213+ return this . _statusStr . length > 0 || this . _nextStatusStr . length > 0 ;
214+ }
215+
216+ async _clearCurrentStatus (
217+ ttyStream : tty . WriteStream ,
218+ statusStr : string
219+ ) : Promise < void > {
220+ const statusLinesCount = statusStr . split ( '\n' ) . length - 1 ;
221+ // extra -1 because we print the status with a trailing new line
222+ this . _isInternalWrite = true ;
223+ try {
224+ await moveCursor ( ttyStream , - ttyStream . columns , - statusLinesCount - 1 ) ;
225+ await clearScreenDown ( ttyStream ) ;
226+ } finally {
227+ this . _isInternalWrite = false ;
228+ }
120229 }
121230
122231 /**
@@ -159,8 +268,8 @@ class Terminal {
159268 this . _shouldFlush = true ;
160269 }
161270 await this . waitForUpdates ( ) ;
162- // @ts -expect-error missing type
163- this . _writeStatusThrottled . flush ( ) ;
271+ // @ts -expect-error missing type on throttle return
272+ this . _writeStatusThrottled . flush ?. ( ) ;
164273 }
165274
166275 /**
@@ -175,29 +284,50 @@ class Terminal {
175284 const nextStatusStr = this . _nextStatusStr ;
176285 const statusStr = this . _statusStr ;
177286 const logLines = this . _logLines ;
287+ const externalWrites = this . _externalWrites ;
178288
179289 // reset these here to not have them changed while updating
180290 this . _statusStr = nextStatusStr ;
181291 this . _logLines = [ ] ;
292+ this . _externalWrites = [ ] ;
182293
183- if ( statusStr === nextStatusStr && logLines . length === 0 ) {
294+ if (
295+ statusStr === nextStatusStr &&
296+ logLines . length === 0 &&
297+ externalWrites . length === 0
298+ ) {
184299 return ;
185300 }
186301
187302 if ( ttyStream && statusStr . length > 0 ) {
188- const statusLinesCount = statusStr . split ( '\n' ) . length - 1 ;
189- // extra -1 because we print the status with a trailing new line
190- await moveCursor ( ttyStream , - ttyStream . columns , - statusLinesCount - 1 ) ;
191- await clearScreenDown ( ttyStream ) ;
303+ await this . _clearCurrentStatus ( ttyStream , statusStr ) ;
192304 }
193305
194306 if ( logLines . length > 0 ) {
195- await streamWrite ( this . _stream , logLines . join ( '\n' ) + '\n' ) ;
307+ await this . _writeInternal ( logLines . join ( '\n' ) + '\n' ) ;
308+ }
309+
310+ if ( externalWrites . length > 0 ) {
311+ // Preserve third-party stdout lines by writing them after the clear and
312+ // before redrawing status. This keeps status live while avoiding "eaten"
313+ // plugin output lines.
314+ for ( const externalWrite of externalWrites ) {
315+ try {
316+ await this . _writeInternal (
317+ externalWrite . chunk ,
318+ externalWrite . encoding
319+ ) ;
320+ externalWrite . callback ?.( null ) ;
321+ } catch ( error ) {
322+ externalWrite . callback ?.( error as Error ) ;
323+ throw error ;
324+ }
325+ }
196326 }
197327
198328 if ( ttyStream ) {
199329 if ( nextStatusStr . length > 0 ) {
200- await streamWrite ( this . _stream , nextStatusStr + '\n' ) ;
330+ await this . _writeInternal ( nextStatusStr + '\n' ) ;
201331 }
202332 } else {
203333 this . _writeStatusThrottled (
0 commit comments