Files
claude-code/src/components/TrustDialog/TrustDialog.tsx
claude-code-best 6dd378bf15 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>
2026-05-22 22:25:51 +08:00

227 lines
8.7 KiB
TypeScript

import { homedir } from 'os';
import React from 'react';
import { logEvent } from 'src/services/analytics/index.js';
import { setSessionTrustAccepted } from '../../bootstrap/state.js';
import type { Command } from '../../commands.js';
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
import { Box, Link, Text } from '@anthropic/ink';
import { useKeybinding } from '../../keybindings/useKeybinding.js';
import { getMcpConfigsByScope } from '../../services/mcp/config.js';
import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js';
import { checkHasTrustDialogAccepted, saveCurrentProjectConfig } from '../../utils/config.js';
import { getCwd } from '../../utils/cwd.js';
import { getFsImplementation } from '../../utils/fsOperations.js';
import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js';
import { Select } from '../CustomSelect/index.js';
import { PermissionDialog } from '../permissions/PermissionDialog.js';
import {
getApiKeyHelperSources,
getAwsCommandsSources,
getBashPermissionSources,
getDangerousEnvVarsSources,
getGcpCommandsSources,
getHooksSources,
getOtelHeadersHelperSources,
} from './utils.js';
type Props = {
onDone(): void;
commands?: Command[];
};
export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
const { servers: projectServers } = getMcpConfigsByScope('project');
// In all cases, we generally check only the project-level and
// project-local-level settings, which we assume that users do not configure
// directly compared to user-level settings.
// Check for MCPs
const hasMcpServers = Object.keys(projectServers).length > 0;
// Check for hooks
const hooksSettingSources = getHooksSources();
const hasHooks = hooksSettingSources.length > 0;
// Check whether code execution is allowed in permissions and slash commands
const bashSettingSources = getBashPermissionSources();
// Check for apiKeyHelper which executes arbitrary commands
const apiKeyHelperSources = getApiKeyHelperSources();
const hasApiKeyHelper = apiKeyHelperSources.length > 0;
// Check for AWS commands which execute arbitrary commands
const awsCommandsSources = getAwsCommandsSources();
const hasAwsCommands = awsCommandsSources.length > 0;
// Check for GCP commands which execute arbitrary commands
const gcpCommandsSources = getGcpCommandsSources();
const hasGcpCommands = gcpCommandsSources.length > 0;
// Check for otelHeadersHelper which executes arbitrary commands
const otelHeadersHelperSources = getOtelHeadersHelperSources();
const hasOtelHeadersHelper = otelHeadersHelperSources.length > 0;
// Check for dangerous environment variables (not in SAFE_ENV_VARS)
const dangerousEnvVarsSources = getDangerousEnvVarsSources();
const hasDangerousEnvVars = dangerousEnvVarsSources.length > 0;
const hasSlashCommandBash =
commands?.some(
command =>
command.type === 'prompt' &&
command.loadedFrom === 'commands_DEPRECATED' &&
(command.source === 'projectSettings' || command.source === 'localSettings') &&
command.allowedTools?.some((tool: string) => tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + '(')),
) ?? false;
const hasSkillsBash =
commands?.some(
command =>
command.type === 'prompt' &&
(command.loadedFrom === 'skills' || command.loadedFrom === 'plugin') &&
(command.source === 'projectSettings' || command.source === 'localSettings' || command.source === 'plugin') &&
command.allowedTools?.some((tool: string) => tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + '(')),
) ?? false;
const hasAnyBashExecution = bashSettingSources.length > 0 || hasSlashCommandBash || hasSkillsBash;
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(() => {
const isHomeDir = homedir() === getCwd();
logEvent('tengu_trust_dialog_shown', {
isHomeDir,
hasMcpServers,
hasHooks,
hasBashExecution: hasAnyBashExecution,
hasApiKeyHelper,
hasAwsCommands,
hasGcpCommands,
hasOtelHeadersHelper,
hasDangerousEnvVars,
});
}, [
hasMcpServers,
hasHooks,
hasAnyBashExecution,
hasApiKeyHelper,
hasAwsCommands,
hasGcpCommands,
hasOtelHeadersHelper,
hasDangerousEnvVars,
]);
function onChange(value: 'enable_all' | 'exit') {
if (value === 'exit') {
// 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;
}
const isHomeDir = homedir() === getCwd();
logEvent('tengu_trust_dialog_accept', {
isHomeDir,
hasMcpServers,
hasHooks,
hasBashExecution: hasAnyBashExecution,
hasApiKeyHelper,
hasAwsCommands,
hasGcpCommands,
hasOtelHeadersHelper,
hasDangerousEnvVars,
});
if (isHomeDir) {
// 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
// while preserving the security intent of not permanently trusting home dir
setSessionTrustAccepted(true);
} else {
saveCurrentProjectConfig(current => ({
...current,
hasTrustDialogAccepted: true,
}));
}
// Do NOT write MCP server settings here. handleMcpjsonServerApprovals in
// interactiveHelpers.tsx runs right after this dialog and shows the per-server approval
// UI. Writing enabledMcpjsonServers/enableAllProjectMcpServers here would
// mark every server 'approved' and silently skip that dialog. See #15558.
onDone();
}
// Default onExit is useApp().exit() → Ink.unmount(), which tears down the
// React tree but never calls onDone(). showSetupScreens() in
// interactiveHelpers.tsx awaits a Promise that only resolves via onDone,
// 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(() => setPendingExitCode(1));
// Use configurable keybinding for ESC to cancel/exit
useKeybinding(
'confirm:no',
() => {
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);
return null;
}
return (
<PermissionDialog color="warning" titleColor="warning" title="Accessing workspace:">
<Box flexDirection="column" gap={1} paddingTop={1}>
<Text bold>{getFsImplementation().cwd()}</Text>
<Text>
Is this a project you trust? (Your own code, a well-known open source project, or work from your team).
</Text>
<Text>Once trusted, Claude Code can read, edit, and run commands in this folder.</Text>
<Text dimColor>
<Link url="https://code.claude.com/docs/en/security">Security guide</Link>
</Text>
<Select
options={[
{ label: 'Yes, I trust this folder', value: 'enable_all' },
{ label: 'No, exit', value: 'exit' },
]}
onChange={value => onChange(value as 'enable_all' | 'exit')}
onCancel={() => onChange('exit')}
/>
<Text dimColor>
{exitState.pending ? <>Press {exitState.keyName} again to exit</> : <>Enter to confirm · Esc to cancel</>}
</Text>
</Box>
</PermissionDialog>
);
}