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

View File

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

View File

@ -1,22 +1,19 @@
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 '../../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 { 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,
@ -25,77 +22,82 @@ import {
getGcpCommandsSources,
getHooksSources,
getOtelHeadersHelperSources,
} from './utils.js'
} from './utils.js';
type Props = {
onDone(): void
commands?: Command[]
}
onDone(): void;
commands?: Command[];
};
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
// 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
const hasMcpServers = Object.keys(projectServers).length > 0;
// Check for hooks
const hooksSettingSources = getHooksSources()
const hasHooks = hooksSettingSources.length > 0
const hooksSettingSources = getHooksSources();
const hasHooks = hooksSettingSources.length > 0;
// Check whether code execution is allowed in permissions and slash commands
const bashSettingSources = getBashPermissionSources()
const bashSettingSources = getBashPermissionSources();
// Check for apiKeyHelper which executes arbitrary commands
const apiKeyHelperSources = getApiKeyHelperSources()
const hasApiKeyHelper = apiKeyHelperSources.length > 0
const apiKeyHelperSources = getApiKeyHelperSources();
const hasApiKeyHelper = apiKeyHelperSources.length > 0;
// Check for AWS commands which execute arbitrary commands
const awsCommandsSources = getAwsCommandsSources()
const hasAwsCommands = awsCommandsSources.length > 0
const awsCommandsSources = getAwsCommandsSources();
const hasAwsCommands = awsCommandsSources.length > 0;
// Check for GCP commands which execute arbitrary commands
const gcpCommandsSources = getGcpCommandsSources()
const hasGcpCommands = gcpCommandsSources.length > 0
const gcpCommandsSources = getGcpCommandsSources();
const hasGcpCommands = gcpCommandsSources.length > 0;
// Check for otelHeadersHelper which executes arbitrary commands
const otelHeadersHelperSources = getOtelHeadersHelperSources()
const hasOtelHeadersHelper = otelHeadersHelperSources.length > 0
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 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
(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
(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 hasAnyBashExecution = 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(() => {
const isHomeDir = homedir() === getCwd()
const isHomeDir = homedir() === getCwd();
logEvent('tengu_trust_dialog_shown', {
isHomeDir,
hasMcpServers,
@ -106,7 +108,7 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
hasGcpCommands,
hasOtelHeadersHelper,
hasDangerousEnvVars,
})
});
}, [
hasMcpServers,
hasHooks,
@ -116,15 +118,20 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
hasGcpCommands,
hasOtelHeadersHelper,
hasDangerousEnvVars,
])
]);
function onChange(value: 'enable_all' | 'exit') {
if (value === 'exit') {
gracefulShutdownSync(1)
return
// 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()
const isHomeDir = homedir() === getCwd();
logEvent('tengu_trust_dialog_accept', {
isHomeDir,
@ -136,18 +143,18 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
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)
setSessionTrustAccepted(true);
} else {
saveCurrentProjectConfig(current => ({
...current,
hasTrustDialogAccepted: true,
}))
}));
}
// 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
// mark every server 'approved' and silently skip that dialog. See #15558.
onDone()
onDone();
}
// 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
// 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)
return null
setTimeout(onDone);
return null;
}
return (
<PermissionDialog
color="warning"
titleColor="warning"
title="Accessing workspace:"
>
<PermissionDialog color="warning" titleColor="warning" title="Accessing workspace:">
<Box flexDirection="column" gap={1} paddingTop={1}>
<Text bold>{getFsImplementation().cwd()}</Text>
<Text>
Quick safety check: Is this a project you created or one you trust?
(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.
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>
<Link url="https://code.claude.com/docs/en/security">Security guide</Link>
</Text>
<Select
@ -218,13 +218,9 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode {
/>
<Text dimColor>
{exitState.pending ? (
<>Press {exitState.keyName} again to exit</>
) : (
<>Enter to confirm · Esc to cancel</>
)}
{exitState.pending ? <>Press {exitState.keyName} again to exit</> : <>Enter to confirm · Esc to cancel</>}
</Text>
</Box>
</PermissionDialog>
)
);
}