From 4aa15ac52abd6c3b64ab10a238221aef1bed59f8 Mon Sep 17 00:00:00 2001 From: IronRookieCoder Date: Fri, 15 May 2026 19:03:35 +0800 Subject: [PATCH] fix(terminal): clear Ink UI on REPL shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 失败 --- packages/@ant/ink/src/core/ink.tsx | 16 +++++++++++ packages/@ant/ink/src/core/termio/csi.ts | 3 ++ src/utils/gracefulShutdown.ts | 36 ++++++++++++------------ 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/packages/@ant/ink/src/core/ink.tsx b/packages/@ant/ink/src/core/ink.tsx index 99a5edb9b..246d28b03 100644 --- a/packages/@ant/ink/src/core/ink.tsx +++ b/packages/@ant/ink/src/core/ink.tsx @@ -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); diff --git a/packages/@ant/ink/src/core/termio/csi.ts b/packages/@ant/ink/src/core/termio/csi.ts index f3b2f524b..13aaeb3fe 100644 --- a/packages/@ant/ink/src/core/termio/csi.ts +++ b/packages/@ant/ink/src/core/termio/csi.ts @@ -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') diff --git a/src/utils/gracefulShutdown.ts b/src/utils/gracefulShutdown.ts index 81c984c66..51f8b5f43 100644 --- a/src/utils/gracefulShutdown.ts +++ b/src/utils/gracefulShutdown.ts @@ -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 {