fix: 退出启动对话框时终端残留一行内容

gracefulShutdownSync 启动异步 shutdown 后同步返回,React 立即
重新渲染组件,与 cleanupTerminalModes() 中的 Ink unmount 产生
竞态条件,导致退出后终端残留对话框内容。

修复方案:引入 pendingExitCode state,退出路径先清空画面
(渲染 null),在 useEffect 中延迟到下一个 tick 再调用
gracefulShutdownSync,确保 Ink 在终端清理前已完成空帧刷新。

影响三个启动对话框:TrustDialog、BypassPermissionsModeDialog、
DevChannelsDialog。

Co-Authored-By: glm-5.1 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best 2026-05-22 22:25:51 +08:00 committed by James Feng
parent 75c6a5e22e
commit 2d4b94ab8e
3 changed files with 169 additions and 160 deletions

View File

@ -1,61 +1,70 @@
import React, { useCallback } from 'react' import React, { useCallback } from 'react';
import { logEvent } from 'src/services/analytics/index.js' import { logEvent } from 'src/services/analytics/index.js';
import { Box, Link, Newline, Text } from '@anthropic/ink' import { Box, Link, Newline, Text } from '@anthropic/ink';
import { gracefulShutdownSync } from '../utils/gracefulShutdown.js' import { gracefulShutdownSync } from '../utils/gracefulShutdown.js';
import { updateSettingsForSource } from '../utils/settings/settings.js' import { updateSettingsForSource } from '../utils/settings/settings.js';
import { Select } from './CustomSelect/index.js' import { Select } from './CustomSelect/index.js';
import { Dialog } from '@anthropic/ink' import { Dialog } from '@anthropic/ink';
type Props = { type Props = {
onAccept(): void onAccept(): void;
} };
export function BypassPermissionsModeDialog({ export function BypassPermissionsModeDialog({ onAccept }: Props): React.ReactNode {
onAccept, const [pendingExitCode, setPendingExitCode] = React.useState<number | null>(null);
}: Props): React.ReactNode {
// 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(() => { React.useEffect(() => {
logEvent('tengu_bypass_permissions_mode_dialog_shown', {}) if (pendingExitCode !== null) {
}, []) const code = pendingExitCode;
const timer = setTimeout(() => gracefulShutdownSync(code));
return () => clearTimeout(timer);
}
}, [pendingExitCode]);
React.useEffect(() => {
logEvent('tengu_bypass_permissions_mode_dialog_shown', {});
}, []);
function onChange(value: 'accept' | 'decline') { function onChange(value: 'accept' | 'decline') {
switch (value) { switch (value) {
case 'accept': { case 'accept': {
logEvent('tengu_bypass_permissions_mode_dialog_accept', {}) logEvent('tengu_bypass_permissions_mode_dialog_accept', {});
updateSettingsForSource('userSettings', { updateSettingsForSource('userSettings', {
skipDangerousModePermissionPrompt: true, skipDangerousModePermissionPrompt: true,
}) });
onAccept() onAccept();
break break;
} }
case 'decline': { case 'decline': {
gracefulShutdownSync(1) setPendingExitCode(1);
break break;
} }
} }
} }
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
gracefulShutdownSync(0) setPendingExitCode(0);
}, []) }, []);
if (pendingExitCode !== null) {
return null;
}
return ( return (
<Dialog <Dialog title="WARNING: Claude Code running in Bypass Permissions mode" color="error" onCancel={handleEscape}>
title="WARNING: Claude Code running in Bypass Permissions mode"
color="error"
onCancel={handleEscape}
>
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
<Text> <Text>
In Bypass Permissions mode, Claude Code will not ask for your approval In Bypass Permissions mode, Claude Code will not ask for your approval before running potentially dangerous
before running potentially dangerous commands. commands.
<Newline /> <Newline />
This mode should only be used in a sandboxed container/VM that has This mode should only be used in a sandboxed container/VM that has restricted internet access and can easily
restricted internet access and can easily be restored if damaged. be restored if damaged.
</Text> </Text>
<Text> <Text>
By proceeding, you accept all responsibility for actions taken while By proceeding, you accept all responsibility for actions taken while running in Bypass Permissions mode.
running in Bypass Permissions mode.
</Text> </Text>
<Link url="https://code.claude.com/docs/en/security" /> <Link url="https://code.claude.com/docs/en/security" />
@ -69,5 +78,5 @@ export function BypassPermissionsModeDialog({
onChange={value => onChange(value as 'accept' | 'decline')} onChange={value => onChange(value as 'accept' | 'decline')}
/> />
</Dialog> </Dialog>
) );
} }

View File

@ -1,54 +1,58 @@
import React, { useCallback } from 'react' import React, { useCallback } from 'react';
import type { ChannelEntry } from '../bootstrap/state.js' import type { ChannelEntry } from '../bootstrap/state.js';
import { Box, Text, Dialog } from '@anthropic/ink' import { Box, Text, Dialog } from '@anthropic/ink';
import { gracefulShutdownSync } from '../utils/gracefulShutdown.js' import { gracefulShutdownSync } from '../utils/gracefulShutdown.js';
import { Select } from './CustomSelect/index.js' import { Select } from './CustomSelect/index.js';
type Props = { type Props = {
channels: ChannelEntry[] channels: ChannelEntry[];
onAccept(): void onAccept(): void;
} };
export function DevChannelsDialog({ channels, onAccept }: Props): React.ReactNode {
const [pendingExitCode, setPendingExitCode] = React.useState<number | null>(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]);
export function DevChannelsDialog({
channels,
onAccept,
}: Props): React.ReactNode {
function onChange(value: 'accept' | 'exit') { function onChange(value: 'accept' | 'exit') {
switch (value) { switch (value) {
case 'accept': case 'accept':
onAccept() onAccept();
break break;
case 'exit': case 'exit':
gracefulShutdownSync(1) setPendingExitCode(1);
break break;
} }
} }
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
gracefulShutdownSync(0) setPendingExitCode(0);
}, []) }, []);
if (pendingExitCode !== null) {
return null;
}
return ( return (
<Dialog <Dialog title="WARNING: Loading development channels" color="error" onCancel={handleEscape}>
title="WARNING: Loading development channels"
color="error"
onCancel={handleEscape}
>
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
<Text> <Text>
--dangerously-load-development-channels is for local channel --dangerously-load-development-channels is for local channel development only. Do not use this option to run
development only. Do not use this option to run channels you have channels you have downloaded off the internet.
downloaded off the internet.
</Text> </Text>
<Text>Please use --channels to run a list of approved channels.</Text> <Text>Please use --channels to run a list of approved channels.</Text>
<Text dimColor> <Text dimColor>
Channels:{' '} Channels:{' '}
{channels {channels
.map(c => .map(c => (c.kind === 'plugin' ? `plugin:${c.name}@${c.marketplace}` : `server:${c.name}`))
c.kind === 'plugin'
? `plugin:${c.name}@${c.marketplace}`
: `server:${c.name}`,
)
.join(', ')} .join(', ')}
</Text> </Text>
</Box> </Box>
@ -61,5 +65,5 @@ export function DevChannelsDialog({
onChange={value => onChange(value as 'accept' | 'exit')} onChange={value => onChange(value as 'accept' | 'exit')}
/> />
</Dialog> </Dialog>
) );
} }

View File

@ -1,22 +1,19 @@
import { homedir } from 'os' import { homedir } from 'os';
import React from 'react' import React from 'react';
import { logEvent } from 'src/services/analytics/index.js' import { logEvent } from 'src/services/analytics/index.js';
import { setSessionTrustAccepted } from '../../bootstrap/state.js' import { setSessionTrustAccepted } from '../../bootstrap/state.js';
import type { Command } from '../../commands.js' import type { Command } from '../../commands.js';
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
import { Box, Link, Text } from '@anthropic/ink' import { Box, Link, Text } from '@anthropic/ink';
import { useKeybinding } from '../../keybindings/useKeybinding.js' import { useKeybinding } from '../../keybindings/useKeybinding.js';
import { getMcpConfigsByScope } from '../../services/mcp/config.js' import { getMcpConfigsByScope } from '../../services/mcp/config.js';
import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js';
import { import { checkHasTrustDialogAccepted, saveCurrentProjectConfig } from '../../utils/config.js';
checkHasTrustDialogAccepted, import { getCwd } from '../../utils/cwd.js';
saveCurrentProjectConfig, import { getFsImplementation } from '../../utils/fsOperations.js';
} from '../../utils/config.js' import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js';
import { getCwd } from '../../utils/cwd.js' import { Select } from '../CustomSelect/index.js';
import { getFsImplementation } from '../../utils/fsOperations.js' import { PermissionDialog } from '../permissions/PermissionDialog.js';
import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js'
import { Select } from '../CustomSelect/index.js'
import { PermissionDialog } from '../permissions/PermissionDialog.js'
import { import {
getApiKeyHelperSources, getApiKeyHelperSources,
getAwsCommandsSources, getAwsCommandsSources,
@ -25,77 +22,82 @@ import {
getGcpCommandsSources, getGcpCommandsSources,
getHooksSources, getHooksSources,
getOtelHeadersHelperSources, getOtelHeadersHelperSources,
} from './utils.js' } from './utils.js';
type Props = { type Props = {
onDone(): void onDone(): void;
commands?: Command[] commands?: Command[];
} };
export function TrustDialog({ onDone, commands }: Props): React.ReactNode { export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
const { servers: projectServers } = getMcpConfigsByScope('project') const { servers: projectServers } = getMcpConfigsByScope('project');
// In all cases, we generally check only the project-level and // In all cases, we generally check only the project-level and
// project-local-level settings, which we assume that users do not configure // project-local-level settings, which we assume that users do not configure
// directly compared to user-level settings. // directly compared to user-level settings.
// Check for MCPs // Check for MCPs
const hasMcpServers = Object.keys(projectServers).length > 0 const hasMcpServers = Object.keys(projectServers).length > 0;
// Check for hooks // Check for hooks
const hooksSettingSources = getHooksSources() const hooksSettingSources = getHooksSources();
const hasHooks = hooksSettingSources.length > 0 const hasHooks = hooksSettingSources.length > 0;
// Check whether code execution is allowed in permissions and slash commands // Check whether code execution is allowed in permissions and slash commands
const bashSettingSources = getBashPermissionSources() const bashSettingSources = getBashPermissionSources();
// Check for apiKeyHelper which executes arbitrary commands // Check for apiKeyHelper which executes arbitrary commands
const apiKeyHelperSources = getApiKeyHelperSources() const apiKeyHelperSources = getApiKeyHelperSources();
const hasApiKeyHelper = apiKeyHelperSources.length > 0 const hasApiKeyHelper = apiKeyHelperSources.length > 0;
// Check for AWS commands which execute arbitrary commands // Check for AWS commands which execute arbitrary commands
const awsCommandsSources = getAwsCommandsSources() const awsCommandsSources = getAwsCommandsSources();
const hasAwsCommands = awsCommandsSources.length > 0 const hasAwsCommands = awsCommandsSources.length > 0;
// Check for GCP commands which execute arbitrary commands // Check for GCP commands which execute arbitrary commands
const gcpCommandsSources = getGcpCommandsSources() const gcpCommandsSources = getGcpCommandsSources();
const hasGcpCommands = gcpCommandsSources.length > 0 const hasGcpCommands = gcpCommandsSources.length > 0;
// Check for otelHeadersHelper which executes arbitrary commands // Check for otelHeadersHelper which executes arbitrary commands
const otelHeadersHelperSources = getOtelHeadersHelperSources() const otelHeadersHelperSources = getOtelHeadersHelperSources();
const hasOtelHeadersHelper = otelHeadersHelperSources.length > 0 const hasOtelHeadersHelper = otelHeadersHelperSources.length > 0;
// Check for dangerous environment variables (not in SAFE_ENV_VARS) // Check for dangerous environment variables (not in SAFE_ENV_VARS)
const dangerousEnvVarsSources = getDangerousEnvVarsSources() const dangerousEnvVarsSources = getDangerousEnvVarsSources();
const hasDangerousEnvVars = dangerousEnvVarsSources.length > 0 const hasDangerousEnvVars = dangerousEnvVarsSources.length > 0;
const hasSlashCommandBash = const hasSlashCommandBash =
commands?.some( commands?.some(
command => command =>
command.type === 'prompt' && command.type === 'prompt' &&
command.loadedFrom === 'commands_DEPRECATED' && command.loadedFrom === 'commands_DEPRECATED' &&
(command.source === 'projectSettings' || (command.source === 'projectSettings' || command.source === 'localSettings') &&
command.source === 'localSettings') && command.allowedTools?.some((tool: string) => tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + '(')),
command.allowedTools?.some( ) ?? false;
(tool: string) =>
tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + '('),
),
) ?? false
const hasSkillsBash = const hasSkillsBash =
commands?.some( commands?.some(
command => command =>
command.type === 'prompt' && command.type === 'prompt' &&
(command.loadedFrom === 'skills' || command.loadedFrom === 'plugin') && (command.loadedFrom === 'skills' || command.loadedFrom === 'plugin') &&
(command.source === 'projectSettings' || (command.source === 'projectSettings' || command.source === 'localSettings' || command.source === 'plugin') &&
command.source === 'localSettings' || command.allowedTools?.some((tool: string) => tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + '(')),
command.source === 'plugin') && ) ?? false;
command.allowedTools?.some(
(tool: string) =>
tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + '('),
),
) ?? false
const hasAnyBashExecution = const hasAnyBashExecution = bashSettingSources.length > 0 || hasSlashCommandBash || hasSkillsBash;
bashSettingSources.length > 0 || hasSlashCommandBash || hasSkillsBash
const hasTrustDialogAccepted = checkHasTrustDialogAccepted() const hasTrustDialogAccepted = checkHasTrustDialogAccepted();
const [pendingExitCode, setPendingExitCode] = React.useState<number | null>(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(() => { React.useEffect(() => {
const isHomeDir = homedir() === getCwd() const isHomeDir = homedir() === getCwd();
logEvent('tengu_trust_dialog_shown', { logEvent('tengu_trust_dialog_shown', {
isHomeDir, isHomeDir,
hasMcpServers, hasMcpServers,
@ -106,7 +108,7 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
hasGcpCommands, hasGcpCommands,
hasOtelHeadersHelper, hasOtelHeadersHelper,
hasDangerousEnvVars, hasDangerousEnvVars,
}) });
}, [ }, [
hasMcpServers, hasMcpServers,
hasHooks, hasHooks,
@ -116,15 +118,20 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
hasGcpCommands, hasGcpCommands,
hasOtelHeadersHelper, hasOtelHeadersHelper,
hasDangerousEnvVars, hasDangerousEnvVars,
]) ]);
function onChange(value: 'enable_all' | 'exit') { function onChange(value: 'enable_all' | 'exit') {
if (value === 'exit') { if (value === 'exit') {
gracefulShutdownSync(1) // Set pendingExitCode to clear the screen before triggering shutdown.
return // 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;
} }
const isHomeDir = homedir() === getCwd() const isHomeDir = homedir() === getCwd();
logEvent('tengu_trust_dialog_accept', { logEvent('tengu_trust_dialog_accept', {
isHomeDir, isHomeDir,
@ -136,18 +143,18 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
hasGcpCommands, hasGcpCommands,
hasOtelHeadersHelper, hasOtelHeadersHelper,
hasDangerousEnvVars, hasDangerousEnvVars,
}) });
if (isHomeDir) { if (isHomeDir) {
// For home directory, store trust in session memory only (not persisted to disk) // For home directory, store trust in session memory only (not persisted to disk)
// This allows hooks and other trust-requiring features to work during this session // This allows hooks and other trust-requiring features to work during this session
// while preserving the security intent of not permanently trusting home dir // while preserving the security intent of not permanently trusting home dir
setSessionTrustAccepted(true) setSessionTrustAccepted(true);
} else { } else {
saveCurrentProjectConfig(current => ({ saveCurrentProjectConfig(current => ({
...current, ...current,
hasTrustDialogAccepted: true, hasTrustDialogAccepted: true,
})) }));
} }
// Do NOT write MCP server settings here. handleMcpjsonServerApprovals in // Do NOT write MCP server settings here. handleMcpjsonServerApprovals in
@ -155,7 +162,7 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
// UI. Writing enabledMcpjsonServers/enableAllProjectMcpServers here would // UI. Writing enabledMcpjsonServers/enableAllProjectMcpServers here would
// mark every server 'approved' and silently skip that dialog. See #15558. // mark every server 'approved' and silently skip that dialog. See #15558.
onDone() onDone();
} }
// Default onExit is useApp().exit() → Ink.unmount(), which tears down the // Default onExit is useApp().exit() → Ink.unmount(), which tears down the
@ -164,48 +171,41 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
// so the default would hang the await forever. With keybinding // so the default would hang the await forever. With keybinding
// customization enabled, the chokidar watcher (persistent: true) keeps the // customization enabled, the chokidar watcher (persistent: true) keeps the
// event loop alive and the process freezes. Explicitly exit 1 like "No". // event loop alive and the process freezes. Explicitly exit 1 like "No".
const exitState = useExitOnCtrlCDWithKeybindings(() => const exitState = useExitOnCtrlCDWithKeybindings(() => setPendingExitCode(1));
gracefulShutdownSync(1),
)
// Use configurable keybinding for ESC to cancel/exit // Use configurable keybinding for ESC to cancel/exit
useKeybinding( useKeybinding(
'confirm:no', 'confirm:no',
() => { () => {
gracefulShutdownSync(0) setPendingExitCode(0);
}, },
{ context: 'Confirmation' }, { 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. // Automatically resolve the trust dialog if there is nothing to be shown.
if (hasTrustDialogAccepted) { if (hasTrustDialogAccepted) {
setTimeout(onDone) setTimeout(onDone);
return null return null;
} }
return ( return (
<PermissionDialog <PermissionDialog color="warning" titleColor="warning" title="Accessing workspace:">
color="warning"
titleColor="warning"
title="Accessing workspace:"
>
<Box flexDirection="column" gap={1} paddingTop={1}> <Box flexDirection="column" gap={1} paddingTop={1}>
<Text bold>{getFsImplementation().cwd()}</Text> <Text bold>{getFsImplementation().cwd()}</Text>
<Text> <Text>
Quick safety check: Is this a project you created or one you trust? Is this a project you trust? (Your own code, a well-known open source project, or work from your team).
(Like your own code, a well-known open source project, or work from
your team). If not, take a moment to review what{"'"}s in this folder
first.
</Text>
<Text>
Claude Code{"'"}ll be able to read, edit, and execute files here.
</Text> </Text>
<Text>Once trusted, Claude Code can read, edit, and run commands in this folder.</Text>
<Text dimColor> <Text dimColor>
<Link url="https://code.claude.com/docs/en/security"> <Link url="https://code.claude.com/docs/en/security">Security guide</Link>
Security guide
</Link>
</Text> </Text>
<Select <Select
@ -218,13 +218,9 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
/> />
<Text dimColor> <Text dimColor>
{exitState.pending ? ( {exitState.pending ? <>Press {exitState.keyName} again to exit</> : <>Enter to confirm · Esc to cancel</>}
<>Press {exitState.keyName} again to exit</>
) : (
<>Enter to confirm · Esc to cancel</>
)}
</Text> </Text>
</Box> </Box>
</PermissionDialog> </PermissionDialog>
) );
} }