fix: 终端内容溢出 viewport 时的重影 bug

主屏幕模式下 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 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-20 22:35:12 +08:00
parent 8db85f1aaf
commit 3d18e1da58
3 changed files with 43 additions and 24 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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