From 6dd378bf15e87f4fb3d0c58896a41a0db87c98a3 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 22 May 2026 22:25:51 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=80=80=E5=87=BA=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86=E6=97=B6=E7=BB=88=E7=AB=AF=E6=AE=8B?= =?UTF-8?q?=E7=95=99=E4=B8=80=E8=A1=8C=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gracefulShutdownSync 启动异步 shutdown 后同步返回,React 立即 重新渲染组件,与 cleanupTerminalModes() 中的 Ink unmount 产生 竞态条件,导致退出后终端残留对话框内容。 修复方案:引入 pendingExitCode state,退出路径先清空画面 (渲染 null),在 useEffect 中延迟到下一个 tick 再调用 gracefulShutdownSync,确保 Ink 在终端清理前已完成空帧刷新。 影响三个启动对话框:TrustDialog、BypassPermissionsModeDialog、 DevChannelsDialog。 Co-Authored-By: glm-5.1 --- .../BypassPermissionsModeDialog.tsx | 20 ++++++++++-- src/components/DevChannelsDialog.tsx | 20 ++++++++++-- src/components/TrustDialog/TrustDialog.tsx | 32 +++++++++++++++++-- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/components/BypassPermissionsModeDialog.tsx b/src/components/BypassPermissionsModeDialog.tsx index cf7118ce3..87bc15dd4 100644 --- a/src/components/BypassPermissionsModeDialog.tsx +++ b/src/components/BypassPermissionsModeDialog.tsx @@ -11,6 +11,18 @@ type Props = { }; export function BypassPermissionsModeDialog({ onAccept }: Props): React.ReactNode { + const [pendingExitCode, setPendingExitCode] = React.useState(null); + + // Clear screen before shutdown so residual dialog content doesn't leak + // to the terminal. Deferred to next tick so Ink flushes the null render. + React.useEffect(() => { + if (pendingExitCode !== null) { + const code = pendingExitCode; + const timer = setTimeout(() => gracefulShutdownSync(code)); + return () => clearTimeout(timer); + } + }, [pendingExitCode]); + React.useEffect(() => { logEvent('tengu_bypass_permissions_mode_dialog_shown', {}); }, []); @@ -27,16 +39,20 @@ export function BypassPermissionsModeDialog({ onAccept }: Props): React.ReactNod break; } case 'decline': { - gracefulShutdownSync(1); + setPendingExitCode(1); break; } } } const handleEscape = useCallback(() => { - gracefulShutdownSync(0); + setPendingExitCode(0); }, []); + if (pendingExitCode !== null) { + return null; + } + return ( diff --git a/src/components/DevChannelsDialog.tsx b/src/components/DevChannelsDialog.tsx index e2fe4bd81..b0aada801 100644 --- a/src/components/DevChannelsDialog.tsx +++ b/src/components/DevChannelsDialog.tsx @@ -10,21 +10,37 @@ type Props = { }; export function DevChannelsDialog({ channels, onAccept }: Props): React.ReactNode { + const [pendingExitCode, setPendingExitCode] = React.useState(null); + + // Clear screen before shutdown so residual dialog content doesn't leak + // to the terminal. Deferred to next tick so Ink flushes the null render. + React.useEffect(() => { + if (pendingExitCode !== null) { + const code = pendingExitCode; + const timer = setTimeout(() => gracefulShutdownSync(code)); + return () => clearTimeout(timer); + } + }, [pendingExitCode]); + function onChange(value: 'accept' | 'exit') { switch (value) { case 'accept': onAccept(); break; case 'exit': - gracefulShutdownSync(1); + setPendingExitCode(1); break; } } const handleEscape = useCallback(() => { - gracefulShutdownSync(0); + setPendingExitCode(0); }, []); + if (pendingExitCode !== null) { + return null; + } + return ( diff --git a/src/components/TrustDialog/TrustDialog.tsx b/src/components/TrustDialog/TrustDialog.tsx index adc911cda..91283c156 100644 --- a/src/components/TrustDialog/TrustDialog.tsx +++ b/src/components/TrustDialog/TrustDialog.tsx @@ -80,6 +80,21 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode { const hasAnyBashExecution = bashSettingSources.length > 0 || hasSlashCommandBash || hasSkillsBash; const hasTrustDialogAccepted = checkHasTrustDialogAccepted(); + const [pendingExitCode, setPendingExitCode] = React.useState(null); + + // When a non-null exit code is set, render null (clear screen) first, + // then trigger shutdown in the next tick so Ink has time to flush + // the empty frame before cleanupTerminalModes() unmounts and exits + // the alt screen. Without this deferral, gracefulShutdownSync starts + // async cleanup immediately after React commit, racing the reconciler + // and leaving residual TrustDialog output on the terminal. + React.useEffect(() => { + if (pendingExitCode !== null) { + const code = pendingExitCode; + const timer = setTimeout(() => gracefulShutdownSync(code)); + return () => clearTimeout(timer); + } + }, [pendingExitCode]); React.useEffect(() => { const isHomeDir = homedir() === getCwd(); @@ -107,7 +122,12 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode { function onChange(value: 'enable_all' | 'exit') { if (value === 'exit') { - gracefulShutdownSync(1); + // Set pendingExitCode to clear the screen before triggering shutdown. + // The useEffect above defers gracefulShutdownSync to the next tick + // so Ink can flush the empty frame first — otherwise + // cleanupTerminalModes races React's re-render and leaves + // residual TrustDialog content on the terminal. + setPendingExitCode(1); return; } @@ -151,17 +171,23 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode { // so the default would hang the await forever. With keybinding // customization enabled, the chokidar watcher (persistent: true) keeps the // event loop alive and the process freezes. Explicitly exit 1 like "No". - const exitState = useExitOnCtrlCDWithKeybindings(() => gracefulShutdownSync(1)); + const exitState = useExitOnCtrlCDWithKeybindings(() => setPendingExitCode(1)); // Use configurable keybinding for ESC to cancel/exit useKeybinding( 'confirm:no', () => { - gracefulShutdownSync(0); + setPendingExitCode(0); }, { context: 'Confirmation' }, ); + // When pendingExitCode is set, render nothing so the screen is cleared + // before shutdown cleans up the alt screen. See the useEffect above. + if (pendingExitCode !== null) { + return null; + } + // Automatically resolve the trust dialog if there is nothing to be shown. if (hasTrustDialogAccepted) { setTimeout(onDone);