From 3d18e1da58332236a1b8b776446631d4a92243e7 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 20 Jun 2026 22:35:12 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=88=E7=AB=AF=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E6=BA=A2=E5=87=BA=20viewport=20=E6=97=B6=E7=9A=84=E9=87=8D?= =?UTF-8?q?=E5=BD=B1=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主屏幕模式下 frame 持续溢出 viewport 时,cursor-restore LF 把内容滚入 scrollback 导致相对光标追踪漂移,可见区 diff 落到错误行产生重影(重复 banner / 错位)。 扩展 log-update overflow 分支为无条件 fullReset(含 \x1b[3J 清 scrollback), 并将主屏 self-healing 清屏从 ERASE_SCREEN (CSI 2 J) 换成 ERASE_DOWN (CSI J), 避免 xterm.js / VSCode 集成终端的 scrollback 边界副作用。 Co-Authored-By: glm-5.2 --- packages/@ant/ink/src/core/ink.tsx | 23 ++++++++++++-- packages/@ant/ink/src/core/log-update.ts | 38 +++++++++++------------- packages/@ant/ink/src/core/termio/csi.ts | 6 ++++ 3 files changed, 43 insertions(+), 24 deletions(-) diff --git a/packages/@ant/ink/src/core/ink.tsx b/packages/@ant/ink/src/core/ink.tsx index 37d3a91c7..ddb80cde8 100644 --- a/packages/@ant/ink/src/core/ink.tsx +++ b/packages/@ant/ink/src/core/ink.tsx @@ -74,6 +74,7 @@ import { DISABLE_MODIFY_OTHER_KEYS, ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS, + ERASE_DOWN, ERASE_SCREEN, } from './termio/csi.js'; import { @@ -106,6 +107,17 @@ 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). @@ -783,10 +795,15 @@ 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. BSU/ESU keeps - // old content visible until the full erase+paint is flushed atomically. + // 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. this.needsEraseBeforePaint = false; - optimized.unshift(ERASE_THEN_HOME_PATCH); + optimized.unshift(HOME_THEN_ERASE_DOWN_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 939bdfda3..e8012d3b7 100644 --- a/packages/@ant/ink/src/core/log-update.ts +++ b/packages/@ant/ink/src/core/log-update.ts @@ -225,27 +225,23 @@ export class LogUpdate { cursorAtBottom && !isGrowing ) { - // 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, - }) - } + // 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) } 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 f3b2f524b..e19c01043 100644 --- a/packages/@ant/ink/src/core/termio/csi.ts +++ b/packages/@ant/ink/src/core/termio/csi.ts @@ -232,6 +232,12 @@ 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