diff --git a/packages/@ant/ink/src/core/ink.tsx b/packages/@ant/ink/src/core/ink.tsx index ddb80cde8..37d3a91c7 100644 --- a/packages/@ant/ink/src/core/ink.tsx +++ b/packages/@ant/ink/src/core/ink.tsx @@ -74,7 +74,6 @@ import { DISABLE_MODIFY_OTHER_KEYS, ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS, - ERASE_DOWN, ERASE_SCREEN, } from './termio/csi.js'; import { @@ -107,17 +106,6 @@ const ERASE_THEN_HOME_PATCH = Object.freeze({ type: 'stdout' as const, content: ERASE_SCREEN + CURSOR_HOME, }); -// Main-screen self-healing: CURSOR_HOME then ERASE_DOWN (CSI J) clears the -// entire visible viewport from (0,0) without touching scrollback. ERASE_SCREEN -// (CSI 2 J) on xterm.js / VSCode integrated terminals can produce residual -// ghosting because its implementation interacts with the scrollback boundary; -// CSI J has deterministic "erase from cursor to end of screen" semantics that -// never push visible content into scrollback. Order matters: home first, then -// erase — so the erase covers the full viewport. -const HOME_THEN_ERASE_DOWN_PATCH = Object.freeze({ - type: 'stdout' as const, - content: CURSOR_HOME + ERASE_DOWN, -}); // Cached per-Ink-instance, invalidated on resize. frame.cursor.y for // alt-screen is always terminalRows - 1 (renderer.ts). @@ -795,15 +783,10 @@ export default class Ink { } else if (this.needsEraseBeforePaint && hasDiff) { // Main-screen periodic self-healing: clear visible terminal before // painting the diff. Without this, rows past the new frame's height - // would retain stale content from the previous frame. Uses - // HOME_THEN_ERASE_DOWN_PATCH (CSI H + CSI J) instead of ERASE_SCREEN - // (CSI 2 J): the latter's behavior on xterm.js / VSCode integrated - // terminals can leave residual ghosting of the prior frame (banner + - // status bar duplicated). CSI J erases from cursor (now at 0,0) to - // end of screen with deterministic semantics and does not touch - // scrollback, so the user's conversation history is preserved. + // would retain stale content from the previous frame. BSU/ESU keeps + // old content visible until the full erase+paint is flushed atomically. this.needsEraseBeforePaint = false; - optimized.unshift(HOME_THEN_ERASE_DOWN_PATCH); + optimized.unshift(ERASE_THEN_HOME_PATCH); } // Native cursor positioning: park the terminal cursor at the declared diff --git a/packages/@ant/ink/src/core/log-update.ts b/packages/@ant/ink/src/core/log-update.ts index e8012d3b7..939bdfda3 100644 --- a/packages/@ant/ink/src/core/log-update.ts +++ b/packages/@ant/ink/src/core/log-update.ts @@ -225,23 +225,27 @@ export class LogUpdate { cursorAtBottom && !isGrowing ) { - // Frame persistently overflows the viewport. The cursor-restore LF at the - // end of the previous frame scrolled content into scrollback, and the - // terminal's auto-scroll on cursor movement causes our relative-cursor - // tracking to drift — visible-region diffs then land on the wrong rows - // and produce ghosting (duplicate banners, shifted content). - // - // Relative cursor ops can't repaint scrollback rows at all, and even - // visible-region writes are unsafe because the cursor origin we computed - // doesn't match where the terminal thinks it is. Full-reset emits - // clearTerminal (CSI 2 J + CSI 3 J + CSI H), wiping scrollback residue - // and cursor drift, then repaints the whole frame from (0,0). - // - // Previously this branch only fired when a diff existed in the scrollback - // region; visible-region-only changes still produced ghosting. Cost: an - // extra clear+repaint per render while content overflows. Acceptable - // because overflow is the exception, not the steady state of a TUI. - return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool) + // viewportY = rows in scrollback from content overflow + // +1 for the row pushed by cursor-restore scroll + const viewportY = prev.screen.height - prev.viewport.height + const scrollbackRows = viewportY + 1 + + let scrollbackChangeY = -1 + diffEach(prev.screen, next.screen, (_x, y) => { + if (y < scrollbackRows) { + scrollbackChangeY = y + return true // early exit + } + }) + if (scrollbackChangeY >= 0) { + const prevLine = readLine(prev.screen, scrollbackChangeY) + const nextLine = readLine(next.screen, scrollbackChangeY) + return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, { + triggerY: scrollbackChangeY, + prevLine, + nextLine, + }) + } } const screen = new VirtualScreen(prev.cursor, next.viewport.width) diff --git a/packages/@ant/ink/src/core/termio/csi.ts b/packages/@ant/ink/src/core/termio/csi.ts index c1b35de58..4c5d1e8a5 100644 --- a/packages/@ant/ink/src/core/termio/csi.ts +++ b/packages/@ant/ink/src/core/termio/csi.ts @@ -227,12 +227,6 @@ export const ERASE_SCREEN = csi(2, 'J') /** Erase scrollback buffer (CSI 3 J) */ export const ERASE_SCROLLBACK = csi(3, 'J') -/** Erase from cursor to end of screen (CSI J) — constant form. - * Unlike ERASE_SCREEN (CSI 2 J), this never pushes content into scrollback - * on xterm.js / VSCode integrated terminals, making it safe for periodic - * self-healing redraws in main-screen mode. */ -export const ERASE_DOWN = csi('J') - /** * Erase n lines starting from cursor line, moving cursor up * This erases each line and moves up, ending at column 1