@@ -165,6 +165,12 @@ export default class Ink {
165165 private frontFrame : Frame ;
166166 private backFrame : Frame ;
167167 private lastPoolResetTime = performance . now ( ) ;
168+ /** Timestamp of last periodic full-redraw in main screen mode. Used to
169+ * recover from accumulated cursor drift / blit ghosting. Wall-clock
170+ * based (not frame-count) so drain scroll frames (250fps) don't
171+ * accelerate the cycle. Alt-screen doesn't need this — CSI H resets
172+ * cursor every frame. */
173+ private lastMainScreenHealTime = performance . now ( ) ;
168174 private drainTimer : ReturnType < typeof setTimeout > | null = null ;
169175 private lastYogaCounters : {
170176 ms : number ;
@@ -521,7 +527,25 @@ export default class Ink {
521527 // an extra React re-render cycle.
522528 this . options . onBeforeRender ?.( ) ;
523529
530+ // Periodic self-healing: every ~5s in main screen mode, force a full
531+ // terminal redraw to recover from accumulated cursor drift / blit
532+ // ghosting. Alt-screen doesn't need this — CSI H resets cursor to
533+ // (0,0) every frame. Wall-clock based so drain scroll frames (250fps)
534+ // don't accelerate the cycle. Guarded by isTTY so ANSI escape
535+ // sequences are not leaked into pipes / redirected output.
524536 const renderStart = performance . now ( ) ;
537+ if (
538+ ! this . altScreenActive &&
539+ ! this . isPaused &&
540+ this . options . stdout . isTTY &&
541+ renderStart - this . lastMainScreenHealTime > 5000
542+ ) {
543+ this . lastMainScreenHealTime = renderStart ;
544+ this . repaint ( ) ;
545+ this . prevFrameContaminated = true ;
546+ this . needsEraseBeforePaint = true ;
547+ }
548+
525549 const terminalWidth = this . options . stdout . columns || 80 ;
526550 const terminalRows = this . options . stdout . rows || 24 ;
527551
@@ -725,6 +749,10 @@ export default class Ink {
725749 const optimized = optimize ( diff ) ;
726750 const optimizeMs = performance . now ( ) - tOptimize ;
727751 const hasDiff = optimized . length > 0 ;
752+ // Periodic self-healing: for main-screen mode, emit ERASE_SCREEN + HOME
753+ // to clear the terminal before the diff. Alt-screen has its own CSI H
754+ // anchor + cursor park below. BSU/ESU wraps erase+paint atomically on
755+ // supported terminals (main-screen always uses sync markers).
728756 if ( this . altScreenActive && hasDiff ) {
729757 // Prepend CSI H to anchor the physical cursor to (0,0) so
730758 // log-update's relative moves compute from a known spot (self-healing
@@ -752,6 +780,13 @@ export default class Ink {
752780 optimized . unshift ( CURSOR_HOME_PATCH ) ;
753781 }
754782 optimized . push ( this . altScreenParkPatch ) ;
783+ } else if ( this . needsEraseBeforePaint && hasDiff ) {
784+ // Main-screen periodic self-healing: clear visible terminal before
785+ // painting the diff. Without this, rows past the new frame's height
786+ // would retain stale content from the previous frame. BSU/ESU keeps
787+ // old content visible until the full erase+paint is flushed atomically.
788+ this . needsEraseBeforePaint = false ;
789+ optimized . unshift ( ERASE_THEN_HOME_PATCH ) ;
755790 }
756791
757792 // Native cursor positioning: park the terminal cursor at the declared
0 commit comments