fix(terminal): clear Ink UI on REPL shutdown

## Bug 详情
使用 /exit 退出交互式 REPL 后,终端可能保留提示输入、命令补全或其他 Ink 临时 UI,导致 shell prompt 与旧 UI 重叠,出现光标和显示异常。

## 根因
shutdown 路径会恢复 raw mode、bracketed paste、focus reporting 等终端模式,但普通主屏幕 REPL 不会清理 Ink 当前占用的可见区域。进程退出前 React 也不一定能完成 /exit 输入清空后的重新渲染。

## 修复方案
在 Ink 实例中增加主屏幕 shutdown 清理方法,根据最后一帧和声明光标位置清除 Ink 占用区域;gracefulShutdown 在非 alternate-screen 场景调用该方法,alternate-screen 继续保留原有退出逻辑。

## 变更要点
- 新增 ERASE_TO_END_SCREEN CSI 常量用于从当前光标位置清到屏幕底部
- 增加 Ink.clearMainScreenForShutdown() 统一计算并清理主屏幕 REPL 区域
- 在 gracefulShutdown 的终端清理阶段区分 alternate-screen 和主屏幕 Ink 实例

## 自测
- 已运行局部 Biome 检查:bunx biome check packages/@ant/ink/src/core/ink.tsx packages/@ant/ink/src/core/termio/csi.ts src/utils/gracefulShutdown.ts
- 已通过 Bun 直接导入验证新增 Ink 方法和 gracefulShutdown 模块可解析
- 全量 typecheck/build 当前被既有无关问题阻断:ctx_viz/LogoV2/doubaoSTT 类型错误,以及 generate-review-builtin 访问 git@github.com:zgsm-ai/costrict-review.git 失败
This commit is contained in:
IronRookieCoder 2026-05-15 19:03:35 +08:00
parent 7512672684
commit 4aa15ac52a
3 changed files with 37 additions and 18 deletions

View File

@ -68,6 +68,7 @@ import {
import { SYNC_OUTPUT_SUPPORTED, supportsExtendedKeys, type Terminal, writeDiffToTerminal } from './terminal.js';
import {
CURSOR_HOME,
CURSOR_LEFT,
cursorMove,
cursorPosition,
DISABLE_KITTY_KEYBOARD,
@ -75,6 +76,7 @@ import {
ENABLE_KITTY_KEYBOARD,
ENABLE_MODIFY_OTHER_KEYS,
ERASE_SCREEN,
ERASE_TO_END_SCREEN,
} from './termio/csi.js';
import {
DBP,
@ -1054,6 +1056,20 @@ export default class Ink {
}
}
/**
* Clear the visible main-screen region currently owned by Ink.
* Used by graceful shutdown for interactive REPL exits, where transient UI
* should not remain underneath the shell prompt.
*/
clearMainScreenForShutdown(): void {
if (!this.options.stdout.isTTY || this.altScreenActive) return;
const cursor = this.displayCursor ?? this.frontFrame.cursor;
const topOffset = Math.min(Math.max(cursor.y, 0), Math.max(this.frontFrame.viewport.height - 1, 0));
const clearFromTop = cursorMove(0, -topOffset) + CURSOR_LEFT + ERASE_TO_END_SCREEN;
writeSync(1, clearFromTop);
}
/** @see drainStdin */
drainStdin(): void {
drainStdin(this.options.stdin);

View File

@ -216,6 +216,9 @@ export function eraseToEndOfScreen(): string {
return csi('J')
}
/** Erase from cursor to end of screen - constant form */
export const ERASE_TO_END_SCREEN = csi('J')
/** Erase from cursor to start of screen (CSI 1 J) */
export function eraseToStartOfScreen(): string {
return csi(1, 'J')

View File

@ -65,23 +65,23 @@ function cleanupTerminalModes(): void {
// we're busy unmounting. Otherwise events arrive during cooked-mode
// cleanup and either echo to the screen or leak to the shell.
writeSync(1, DISABLE_MOUSE_TRACKING)
// Exit alt screen FIRST so printResumeHint() (and all sequences below)
// land on the main buffer.
//
// Unmount Ink directly rather than writing EXIT_ALT_SCREEN ourselves.
// Ink registered its unmount with signal-exit, so it will otherwise run
// AGAIN inside forceExit() → process.exit(). Two problems with letting
// that happen:
// 1. If we write 1049l here and unmount writes it again later, the
// second one triggers another DECRC — the cursor jumps back over
// the resume hint and the shell prompt lands on the wrong line.
// 2. unmount()'s onRender() must run with altScreenActive=true (alt-
// screen cursor math) AND on the alt buffer. Exiting alt-screen
// here first makes onRender() scribble a REPL frame onto main.
// Calling unmount() now does the final render on the alt buffer,
// unsubscribes from signal-exit, and writes 1049l exactly once.
const inst = instances.get(process.stdout)
if (inst?.isAltScreenActive) {
// Exit alt screen FIRST so printResumeHint() (and all sequences below)
// land on the main buffer.
//
// Unmount Ink directly rather than writing EXIT_ALT_SCREEN ourselves.
// Ink registered its unmount with signal-exit, so it will otherwise run
// AGAIN inside forceExit() -> process.exit(). Two problems with letting
// that happen:
// 1. If we write 1049l here and unmount writes it again later, the
// second one triggers another DECRC — the cursor jumps back over
// the resume hint and the shell prompt lands on the wrong line.
// 2. unmount()'s onRender() must run with altScreenActive=true (alt-
// screen cursor math) AND on the alt buffer. Exiting alt-screen
// here first makes onRender() scribble a REPL frame onto main.
// Calling unmount() now does the final render on the alt buffer,
// unsubscribes from signal-exit, and writes 1049l exactly once.
try {
inst.unmount()
} catch {
@ -89,6 +89,8 @@ function cleanupTerminalModes(): void {
// so printResumeHint still hits the main buffer.
writeSync(1, EXIT_ALT_SCREEN)
}
} else if (inst) {
inst.clearMainScreenForShutdown()
}
// Catches events that arrived during the unmount tree-walk.
// detachForShutdown() below also drains.
@ -169,9 +171,7 @@ function printResumeHint(): void {
writeSync(
1,
chalk.dim(
`\nResume this session with:\ncsc --resume ${resumeArg}\n`,
),
chalk.dim(`\nResume this session with:\ncsc --resume ${resumeArg}\n`),
)
resumeHintPrinted = true
} catch {