- Help General 页添加 3 步 Getting started 引导,替代单段描述 - 权限对话框底部 "Esc to cancel" → "Esc to reject","Tab to amend" → "Tab to add feedback" - .claude/ 文件夹权限选项标签从 60 字符缩至 49 字符,避免窄终端截断 - 新增 10 个测试覆盖权限提示文案和帮助页引导内容 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
183 lines
6.2 KiB
TypeScript
183 lines
6.2 KiB
TypeScript
import { homedir } from 'os';
|
|
import { basename, join, sep } from 'path';
|
|
import React, { type ReactNode } from 'react';
|
|
import { getOriginalCwd } from '../../../bootstrap/state.js';
|
|
import { Text } from '@anthropic/ink';
|
|
import { getShortcutDisplay } from '../../../keybindings/shortcutFormat.js';
|
|
import type { ToolPermissionContext } from '../../../Tool.js';
|
|
import { expandPath, getDirectoryForPath } from '../../../utils/path.js';
|
|
import { normalizeCaseForComparison, pathInAllowedWorkingPath } from '../../../utils/permissions/filesystem.js';
|
|
import type { OptionWithDescription } from '../../CustomSelect/select.js';
|
|
/**
|
|
* Check if a path is within the project's .claude/ folder.
|
|
* This is used to determine whether to show the special ".claude folder" permission option.
|
|
*/
|
|
export function isInClaudeFolder(filePath: string): boolean {
|
|
const absolutePath = expandPath(filePath);
|
|
const claudeFolderPath = expandPath(`${getOriginalCwd()}/.claude`);
|
|
|
|
// Check if the path is within the project's .claude folder
|
|
const normalizedAbsolutePath = normalizeCaseForComparison(absolutePath);
|
|
const normalizedClaudeFolderPath = normalizeCaseForComparison(claudeFolderPath);
|
|
|
|
// Path must start with the .claude folder path (and be inside it, not just the folder itself)
|
|
return (
|
|
normalizedAbsolutePath.startsWith(normalizedClaudeFolderPath + sep.toLowerCase()) ||
|
|
// Also match case where sep is / on posix systems
|
|
normalizedAbsolutePath.startsWith(normalizedClaudeFolderPath + '/')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if a path is within the global ~/.claude/ folder.
|
|
* This is used to determine whether to show the special ".claude folder" permission option
|
|
* for files in the user's home directory.
|
|
*/
|
|
export function isInGlobalClaudeFolder(filePath: string): boolean {
|
|
const absolutePath = expandPath(filePath);
|
|
const globalClaudeFolderPath = join(homedir(), '.claude');
|
|
|
|
const normalizedAbsolutePath = normalizeCaseForComparison(absolutePath);
|
|
const normalizedGlobalClaudeFolderPath = normalizeCaseForComparison(globalClaudeFolderPath);
|
|
|
|
return (
|
|
normalizedAbsolutePath.startsWith(normalizedGlobalClaudeFolderPath + sep.toLowerCase()) ||
|
|
normalizedAbsolutePath.startsWith(normalizedGlobalClaudeFolderPath + '/')
|
|
);
|
|
}
|
|
|
|
export type PermissionOption =
|
|
| { type: 'accept-once' }
|
|
| { type: 'accept-session'; scope?: 'claude-folder' | 'global-claude-folder' }
|
|
| { type: 'reject' };
|
|
|
|
export type PermissionOptionWithLabel = OptionWithDescription<string> & {
|
|
option: PermissionOption;
|
|
};
|
|
|
|
export type FileOperationType = 'read' | 'write' | 'create';
|
|
|
|
export function getFilePermissionOptions({
|
|
filePath,
|
|
toolPermissionContext,
|
|
operationType = 'write',
|
|
onRejectFeedbackChange,
|
|
onAcceptFeedbackChange,
|
|
yesInputMode = false,
|
|
noInputMode = false,
|
|
}: {
|
|
filePath: string;
|
|
toolPermissionContext: ToolPermissionContext;
|
|
operationType?: FileOperationType;
|
|
onRejectFeedbackChange?: (value: string) => void;
|
|
onAcceptFeedbackChange?: (value: string) => void;
|
|
yesInputMode?: boolean;
|
|
noInputMode?: boolean;
|
|
}): PermissionOptionWithLabel[] {
|
|
const options: PermissionOptionWithLabel[] = [];
|
|
const modeCycleShortcut = getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab');
|
|
|
|
// When in input mode, show input field
|
|
if (yesInputMode && onAcceptFeedbackChange) {
|
|
options.push({
|
|
type: 'input',
|
|
label: 'Yes',
|
|
value: 'yes',
|
|
placeholder: 'and tell Claude what to do next',
|
|
onChange: onAcceptFeedbackChange,
|
|
allowEmptySubmitToCancel: true,
|
|
option: { type: 'accept-once' },
|
|
});
|
|
} else {
|
|
options.push({
|
|
label: 'Yes',
|
|
value: 'yes',
|
|
option: { type: 'accept-once' },
|
|
});
|
|
}
|
|
|
|
const inAllowedPath = pathInAllowedWorkingPath(filePath, toolPermissionContext);
|
|
|
|
// Check if this is a .claude/ folder path (project or global)
|
|
const inClaudeFolder = isInClaudeFolder(filePath);
|
|
const inGlobalClaudeFolder = isInGlobalClaudeFolder(filePath);
|
|
|
|
// Option 2: For .claude/ folder, show special option instead of generic session option
|
|
// Note: Session-level options are always shown since they only affect in-memory state,
|
|
// not persisted settings. The allowManagedPermissionRulesOnly setting only restricts
|
|
// persisted permission rules.
|
|
if ((inClaudeFolder || inGlobalClaudeFolder) && operationType !== 'read') {
|
|
options.push({
|
|
label: 'Yes, allow edits to .claude/ config for this session',
|
|
value: 'yes-claude-folder',
|
|
option: {
|
|
type: 'accept-session',
|
|
scope: inGlobalClaudeFolder ? 'global-claude-folder' : 'claude-folder',
|
|
},
|
|
});
|
|
} else {
|
|
// Option 2: Allow all changes/reads during session
|
|
let sessionLabel: ReactNode;
|
|
|
|
if (inAllowedPath) {
|
|
// Inside working directory
|
|
if (operationType === 'read') {
|
|
sessionLabel = 'Yes, during this session';
|
|
} else {
|
|
sessionLabel = (
|
|
<Text>
|
|
Yes, allow all edits during this session <Text bold>({modeCycleShortcut})</Text>
|
|
</Text>
|
|
);
|
|
}
|
|
} else {
|
|
// Outside working directory - include directory name
|
|
const dirPath = getDirectoryForPath(filePath);
|
|
const dirName = basename(dirPath) || 'this directory';
|
|
|
|
if (operationType === 'read') {
|
|
sessionLabel = (
|
|
<Text>
|
|
Yes, allow reading from <Text bold>{dirName}/</Text> during this session
|
|
</Text>
|
|
);
|
|
} else {
|
|
sessionLabel = (
|
|
<Text>
|
|
Yes, allow all edits in <Text bold>{dirName}/</Text> during this session{' '}
|
|
<Text bold>({modeCycleShortcut})</Text>
|
|
</Text>
|
|
);
|
|
}
|
|
}
|
|
|
|
options.push({
|
|
label: sessionLabel,
|
|
value: 'yes-session',
|
|
option: { type: 'accept-session' },
|
|
});
|
|
}
|
|
|
|
// When in input mode, show input field for reject
|
|
if (noInputMode && onRejectFeedbackChange) {
|
|
options.push({
|
|
type: 'input',
|
|
label: 'No',
|
|
value: 'no',
|
|
placeholder: 'and tell Claude what to do differently',
|
|
onChange: onRejectFeedbackChange,
|
|
allowEmptySubmitToCancel: true,
|
|
option: { type: 'reject' },
|
|
});
|
|
} else {
|
|
// Not in input mode - simple option
|
|
options.push({
|
|
label: 'No',
|
|
value: 'no',
|
|
option: { type: 'reject' },
|
|
});
|
|
}
|
|
|
|
return options;
|
|
}
|