11/**
2- * ViewportRenderer - Diff-based terminal rendering with scrollback preservation
2+ * ViewportRenderer - Terminal rendering with scrollback preservation
33 *
44 * This module implements a terminal UI renderer that:
55 * - Renders to a fixed "viewport" region at the bottom of the terminal
6- * - Uses diff-based updates to minimize flicker and escape sequences
6+ * - Uses cursor positioning to update content in-place
77 * - Preserves native terminal scrollback for committed output
88 * - Supports terminal resize handling
99 *
1212
1313import { Writable } from 'stream' ;
1414
15- // ansi-diff for minimal diff-based rendering
16- // eslint-disable-next-line @typescript-eslint/no-require-imports
17- const ansiDiff = require ( 'ansi-diff' ) ;
18-
1915/**
2016 * ANSI escape codes for terminal control
2117 */
@@ -28,9 +24,10 @@ const ANSI = {
2824 saveCursor : '\x1B7' ,
2925 restoreCursor : '\x1B8' ,
3026 cursorTo : ( row : number , col : number ) => `\x1B[${ row } ;${ col } H` ,
31- cursorUp : ( n : number ) => `\x1B[${ n } A` ,
32- cursorDown : ( n : number ) => `\x1B[${ n } B` ,
27+ cursorUp : ( n : number ) => n > 0 ? `\x1B[${ n } A` : '' ,
28+ cursorDown : ( n : number ) => n > 0 ? `\x1B[${ n } B` : '' ,
3329 cursorToColumn : ( col : number ) => `\x1B[${ col } G` ,
30+ cursorToStart : '\r' ,
3431
3532 // Line clearing (does NOT affect scrollback)
3633 clearLine : '\x1B[2K' ,
@@ -66,17 +63,17 @@ export interface ViewportState {
6663 * Key concepts:
6764 * - "Committed output" is written normally and becomes part of scrollback
6865 * - "Live viewport" is a reserved region that gets updated in-place
69- * - Uses ansi-diff for minimal escape sequence output
66+ * - Uses cursor positioning to redraw content efficiently
7067 */
7168export class ViewportRenderer {
7269 private output : Writable ;
7370 private viewportHeight : number ;
7471 private hideCursorEnabled : boolean ;
75- private diff : ReturnType < typeof ansiDiff > ;
7672 private isRunning : boolean = false ;
7773 private terminalWidth : number ;
7874 private terminalHeight : number ;
7975 private resizeHandler : ( ( ) => void ) | null = null ;
76+ private hasRenderedOnce : boolean = false ;
8077
8178 constructor ( options : ViewportRendererOptions = { } ) {
8279 this . output = options . output ?? process . stdout ;
@@ -87,12 +84,6 @@ export class ViewportRenderer {
8784 const tty = this . output as NodeJS . WriteStream ;
8885 this . terminalWidth = tty . columns ?? 80 ;
8986 this . terminalHeight = tty . rows ?? 24 ;
90-
91- // Initialize ansi-diff with terminal dimensions
92- this . diff = ansiDiff ( {
93- width : this . terminalWidth ,
94- height : this . viewportHeight ,
95- } ) ;
9687 }
9788
9889 /**
@@ -117,13 +108,11 @@ export class ViewportRenderer {
117108 */
118109 setViewportHeight ( height : number ) : void {
119110 this . viewportHeight = height ;
120- this . diff . resize ( { height } ) ;
121111 }
122112
123113 /**
124114 * Start the viewport renderer
125115 * - Sets up resize handling
126- * - Reserves space for the viewport
127116 * - Hides cursor if configured
128117 */
129118 start ( ) : this {
@@ -135,10 +124,6 @@ export class ViewportRenderer {
135124 const tty = this . output as NodeJS . WriteStream ;
136125 this . terminalWidth = tty . columns ?? 80 ;
137126 this . terminalHeight = tty . rows ?? 24 ;
138- this . diff . resize ( {
139- width : this . terminalWidth ,
140- height : this . viewportHeight ,
141- } ) ;
142127 } ;
143128
144129 if ( typeof ( this . output as NodeJS . WriteStream ) . on === 'function' ) {
@@ -150,63 +135,37 @@ export class ViewportRenderer {
150135 this . write ( ANSI . hideCursor ) ;
151136 }
152137
153- // Reserve space for viewport by printing empty lines
154- // This ensures we have room to render without scrolling
155- this . reserveViewportSpace ( ) ;
156-
157138 return this ;
158139 }
159140
160- /**
161- * Reserve space for the viewport at the bottom of the terminal
162- */
163- private reserveViewportSpace ( ) : void {
164- // Print empty lines to ensure viewport space exists
165- for ( let i = 0 ; i < this . viewportHeight ; i ++ ) {
166- this . write ( '\n' ) ;
167- }
168- // Move cursor back up to the start of viewport
169- this . write ( ANSI . cursorUp ( this . viewportHeight ) ) ;
170- }
171-
172141 /**
173142 * Write committed output that becomes part of scrollback
174143 * This should be used for completed messages, not live updates
175144 */
176145 commit ( text : string ) : this {
177- if ( ! this . isRunning ) {
178- this . write ( text ) ;
179- return this ;
146+ // First, clear the current viewport by moving up and clearing lines
147+ if ( this . hasRenderedOnce ) {
148+ this . write ( ANSI . cursorUp ( this . viewportHeight ) ) ;
149+ for ( let i = 0 ; i < this . viewportHeight ; i ++ ) {
150+ this . write ( ANSI . clearLine + '\n' ) ;
151+ }
152+ this . write ( ANSI . cursorUp ( this . viewportHeight ) ) ;
180153 }
181154
182- // Save cursor position
183- this . write ( ANSI . saveCursor ) ;
184-
185- // Move to the line above the viewport
186- // We need to scroll the viewport content up first
187- this . write ( ANSI . cursorUp ( this . viewportHeight ) ) ;
188-
189- // Write the committed text (this will scroll naturally)
155+ // Write the committed text (this becomes scrollback)
190156 this . write ( text ) ;
191157 if ( ! text . endsWith ( '\n' ) ) {
192158 this . write ( '\n' ) ;
193159 }
194160
195- // Re-reserve viewport space
196- this . reserveViewportSpace ( ) ;
197-
198- // Force a full redraw of the viewport
199- this . diff = ansiDiff ( {
200- width : this . terminalWidth ,
201- height : this . viewportHeight ,
202- } ) ;
161+ this . hasRenderedOnce = false ;
203162
204163 return this ;
205164 }
206165
207166 /**
208167 * Render the viewport with the given state
209- * Uses diff-based rendering to minimize escape sequences
168+ * Uses cursor positioning to redraw content in-place
210169 */
211170 render ( state : ViewportState ) : this {
212171 if ( ! this . isRunning ) {
@@ -221,25 +180,31 @@ export class ViewportRenderer {
221180 lines . push ( '' ) ;
222181 }
223182
224- // Join lines and compute diff
225- const content = lines . join ( '\n' ) ;
226- const diffOutput = this . diff . update ( content ) ;
183+ // On first render, reserve space by printing empty lines
184+ // This establishes the viewport region
185+ if ( ! this . hasRenderedOnce ) {
186+ for ( let i = 0 ; i < this . viewportHeight ; i ++ ) {
187+ this . write ( '\n' ) ;
188+ }
189+ this . hasRenderedOnce = true ;
190+ }
227191
228- // Save cursor, move to viewport start, apply diff, restore cursor
229- this . write ( ANSI . saveCursor ) ;
230- this . write ( diffOutput ) ;
192+ // Move cursor back to start of viewport
193+ this . write ( ANSI . cursorUp ( this . viewportHeight ) ) ;
194+ this . write ( ANSI . cursorToStart ) ;
231195
232- // Position cursor if specified
233- if ( state . cursorRow !== undefined && state . cursorCol !== undefined ) {
234- // Calculate absolute position within viewport
235- const row = Math . min ( state . cursorRow , this . viewportHeight - 1 ) ;
236- const col = state . cursorCol ;
237- this . write ( ANSI . cursorUp ( this . viewportHeight - 1 - row ) ) ;
238- this . write ( ANSI . cursorToColumn ( col + 1 ) ) ; // ANSI columns are 1-indexed
239- } else {
240- this . write ( ANSI . restoreCursor ) ;
196+ // Clear and redraw each line
197+ for ( let i = 0 ; i < lines . length ; i ++ ) {
198+ this . write ( ANSI . clearLine ) ;
199+ this . write ( lines [ i ] ) ;
200+ if ( i < lines . length - 1 ) {
201+ this . write ( '\n' ) ;
202+ }
241203 }
242204
205+ // Move cursor to end of viewport (for next render)
206+ this . write ( '\n' ) ;
207+
243208 return this ;
244209 }
245210
@@ -255,7 +220,6 @@ export class ViewportRenderer {
255220 * Stop the viewport renderer
256221 * - Removes resize handler
257222 * - Shows cursor
258- * - Clears viewport
259223 */
260224 stop ( ) : this {
261225 if ( ! this . isRunning ) return this ;
@@ -267,10 +231,6 @@ export class ViewportRenderer {
267231 this . resizeHandler = null ;
268232 }
269233
270- // Clear viewport and move cursor to end
271- this . clear ( ) ;
272- this . write ( ANSI . cursorDown ( this . viewportHeight ) ) ;
273-
274234 // Show cursor
275235 if ( this . hideCursorEnabled ) {
276236 this . write ( ANSI . showCursor ) ;
0 commit comments