5.5 权限交互:渐进式信任的 UI 设计

5.5 权限交互:渐进式信任的 UI 设计

对应原书 :5.9 权限 UX:渐进式信任的交互设计(计划编号 5.5 权限交互)

辅助源码components/permissions/(50 个文件,~5000 行)

核心文件:PermissionRequest.tsx / PermissionDialog.tsx / PermissionPrompt.tsx / BashPermissionRequest.tsx / FileEditPermissionRequest.tsx / PermissionExplanation.tsx / hooks.ts / bashToolUseOptions.tsx


1. 设计哲学:按需打断、记住偏好、透明可控

权限系统的后端再精巧,如果 UX 设计不好,用户要么"一路狂点 Yes"(失去安全意义),要么"受够了弹框关掉 Agent"(失去产品价值)。Claude Code 的权限 UX 围绕三个原则设计:

原则 含义 源码体现
按需打断 只在真正需要用户决策时才弹出权限对话框 14步决策树中只有第3步(默认ASK)才触发UI;allow/deny 静默处理
记住偏好 提供"下次不再询问"机制,减少重复打断 可编辑前缀规则 yes-prefix-edited / 会话级允许 accept-session
透明可控 解释为什么需要权限,允许事后管理规则 PermissionRuleExplanation + Ctrl+E AI解释 + Ctrl+D 调试信息 + /permissions 命令

2. 三层组件架构

权限 UI 采用三层架构,将关注点清晰分离:

复制代码
PermissionRequest(路由层)
  ├── permissionComponentForTool() → 按工具类型分发
  │     ├── BashTool → BashPermissionRequest
  │     ├── FileEditTool → FileEditPermissionRequest
  │     ├── FileWriteTool → FileWritePermissionRequest
  │     ├── GlobTool/GrepTool/FileReadTool → FilesystemPermissionRequest(共用)
  │     ├── WebFetchTool → WebFetchPermissionRequest
  │     ├── ExitPlanModeV2Tool → ExitPlanModePermissionRequest
  │     ├── AskUserQuestionTool → AskUserQuestionPermissionRequest
  │     ├── NotebookEditTool → NotebookEditPermissionRequest
  │     ├── PowerShellTool → PowerShellPermissionRequest
  │     ├── SkillTool → SkillPermissionRequest
  │     ├── EnterPlanModeTool → EnterPlanModePermissionRequest
  │     ├── ReviewArtifactTool → ReviewArtifactPermissionRequest (Feature Flag)
  │     ├── WorkflowTool → WorkflowPermissionRequest (Feature Flag)
  │     ├── MonitorTool → MonitorPermissionRequest (Feature Flag)
  │     └── default → FallbackPermissionRequest(通用回退)
  │
  └── PermissionDialog(容器层)
        ├── PermissionRequestTitle(标题 + Worker Badge + 副标题)
        ├── children(工具特定内容区)
        └── PermissionPrompt / Select(交互层)

2.1 路由层:PermissionRequest

源码位置PermissionRequest.tsx

typescript 复制代码
function permissionComponentForTool(tool: Tool): React.ComponentType<PermissionRequestProps> {
  switch (tool) {
    case FileEditTool:     return FileEditPermissionRequest;
    case BashTool:         return BashPermissionRequest;
    case FileWriteTool:    return FileWritePermissionRequest;
    case GlobTool:
    case GrepTool:
    case FileReadTool:     return FilesystemPermissionRequest;  // 共用组件
    case ExitPlanModeV2Tool: return ExitPlanModePermissionRequest;
    case AskUserQuestionTool: return AskUserQuestionPermissionRequest;
    // ... Feature Flag 门控的工具
    case ReviewArtifactTool:
      return ReviewArtifactPermissionRequest ?? FallbackPermissionRequest;  // 降级
    default:
      return FallbackPermissionRequest;
  }
}

关键设计

  • 一个工具一个 UI 策略------不同工具的风险谱和用户关注点完全不同
  • Feature Flag 门控的工具使用 ?? FallbackPermissionRequest 降级,确保 Feature Flag 关闭时不崩溃
  • PermissionRequest 组件本身做三件事:
    1. 注册 app:interrupt 快捷键(Esc 中断)
    2. 调用 useNotifyAfterTimeout 注册离席通知
    3. 渲染工具特定的权限组件

2.2 容器层:PermissionDialog

源码位置PermissionDialog.tsx

typescript 复制代码
type Props = {
  title: string;
  subtitle?: React.ReactNode;       // 副标题(如分类器状态)
  color?: keyof Theme;               // 边框颜色,默认 "permission"
  titleColor?: keyof Theme;
  innerPaddingX?: number;            // 内边距,默认 1
  workerBadge?: WorkerBadgeProps;    // 子 Agent 徽章
  titleRight?: React.ReactNode;      // 标题右侧内容
  children: React.ReactNode;
};

视觉特征

  • 圆角顶部边框(borderStyle="round"),仅保留顶边(borderLeft/Right/Bottom = false
  • marginTop={1} 与上方内容分隔
  • 标题行使用 PermissionRequestTitle:粗体标题 + Worker Badge + 副标题
  • 内容区 paddingX 可配置

2.3 交互层:PermissionPrompt

源码位置PermissionPrompt.tsx

PermissionPrompt 是通用的选项+反馈输入组件,被 FallbackPermissionRequest 等组件使用:

typescript 复制代码
export type PermissionPromptOption<T extends string> = {
  value: T;
  label: ReactNode;
  feedbackConfig?: {
    type: FeedbackType;           // 'accept' | 'reject'
    placeholder?: string;
  };
  keybinding?: KeybindingAction;
};

核心机制

  • options 数组动态生成,每个选项可携带 feedbackConfig

  • Tab 键展开反馈输入框 :聚焦在带 feedbackConfig 的选项上时显示 "Tab to amend" 提示

  • 上下文敏感占位符

    typescript 复制代码
    const DEFAULT_PLACEHOLDERS: Record<FeedbackType, string> = {
      accept: 'tell Claude what to do next',       // 引导下一步
      reject: 'tell Claude what to do differently' // 引导替代方案
    };
  • 空提交取消allowEmptySubmitToCancel: true------在输入模式下空提交退出输入模式,而非提交空反馈

  • Esc 取消 :记录 tengu_permission_request_escape 事件 + 增加 escapeCount(用于归因追踪)


3. Bash 权限的渐进式信任

源码位置BashPermissionRequest/BashPermissionRequest.tsx(481 行,最复杂的权限组件)

3.1 四路选项体系

Bash 权限对话框是整个 UX 中最精心设计的部分,体现了"渐进式信任"的核心思想:

选项值 持久性 信任级别 场景
yes 最低 仅本次 不确定的命令
yes-apply-suggestions 中等 按后端建议 后端已分析的安全命令
yes-prefix-edited 较高 持久化到设置 npm run:*git:* 等模式
yes-classifier-reviewed 会话级 AI 分类器描述 分类器判定为安全的命令
no --- 拒绝 不允许执行

选项生成bashToolUseOptions.tsx

typescript 复制代码
export type BashToolUseOption = 'yes' | 'yes-apply-suggestions' | 'yes-prefix-edited' 
                              | 'yes-classifier-reviewed' | 'no';

export function bashToolUseOptions({...}): OptionWithDescription<BashToolUseOption>[] {
  const options: OptionWithDescription<BashToolUseOption>[] = [];
  
  // 1. Yes(带可选反馈输入)
  options.push(yesInputMode 
    ? { type: 'input', label: 'Yes', value: 'yes', placeholder: 'and tell Claude what to do next', ... }
    : { label: 'Yes', value: 'yes' }
  );
  
  // 2. 持久化选项(受 shouldShowAlwaysAllowOptions() 门控)
  if (shouldShowAlwaysAllowOptions()) {
    // 2a. 可编辑前缀规则(优先)
    if (editablePrefix !== undefined && !hasNonBashSuggestions && suggestions.length > 0) {
      options.push({
        type: 'input',
        label: 'Yes, and don\u2019t ask again for',
        value: 'yes-prefix-edited',
        placeholder: 'command prefix (e.g., npm run:*)',
        initialValue: editablePrefix,
        showLabelWithValue: true,
        ...
      });
    }
    // 2b. 后端建议(回退)
    else if (suggestions.length > 0) {
      options.push({ label: generateShellSuggestionsLabel(...), value: 'yes-apply-suggestions' });
    }
    
    // 2c. 分类器描述规则(仅 ant 构建 + Feature Flag)
    if ("external" === 'ant' && !editablePrefixShown && isClassifierPermissionsEnabled() && ...) {
      options.push({
        type: 'input',
        label: 'Yes, and don\u2019t ask again for',
        value: 'yes-classifier-reviewed',
        placeholder: 'describe what to allow...',
        ...
      });
    }
  }
  
  // 3. No(带可选反馈输入)
  options.push(noInputMode
    ? { type: 'input', label: 'No', value: 'no', placeholder: 'and tell Claude what to do differently', ... }
    : { label: 'No', value: 'no' }
  );
  
  return options;
}

3.2 可编辑前缀规则

核心设计 :当系统检测到命令符合"主命令+子命令"模式时(如 npm run build),预填充一个可编辑的通配符规则 npm run:*

前缀提取算法BashPermissionRequestInner 中):

typescript 复制代码
const [editablePrefix, setEditablePrefix] = useState<string | undefined>(() => {
  if (isCompound) {
    // 复合命令:后端建议是真相源
    const backendBashRules = extractRules(suggestions).filter(r => r.toolName === BashTool.name);
    return backendBashRules.length === 1 ? backendBashRules[0]!.ruleContent : undefined;
  }
  // 同步快速提取(不依赖 tree-sitter)
  const two = getSimpleCommandPrefix(command);  // 两词前缀:npm run → npm run:*
  if (two) return `${two}:*`;
  const one = getFirstWordPrefix(command);       // 单词前缀:git → git:*
  if (one) return `${one}:*`;
  return command;                                 // 回退到完整命令
});

安全防护BARE_SHELL_PREFIXES 集合阻止生成过于宽泛的规则。例如 bash -c "rm -rf /" 不会生成 bash:*,因为 bash:* 允许执行任何 shell 脚本,风险不可控。

异步精化(tree-sitter):

typescript 复制代码
useEffect(() => {
  if (isCompound) return;  // 复合命令跳过,后端已分析
  let cancelled = false;
  getCompoundCommandPrefixesStatic(command, subcmd => BashTool.isReadOnly({ command: subcmd }))
    .then(prefixes => {
      if (cancelled || hasUserEditedPrefix.current) return;  // 用户已手动编辑则不覆盖
      if (prefixes.length > 0) setEditablePrefix(`${prefixes[0]}:*`);
    }).catch(() => {});
  return () => { cancelled = true; };
}, [command, isCompound]);

复合命令处理(GH#11380):

  • decisionReason.type === 'subcommandResults' 标记复合命令路径
  • 同步前缀启发式(getSimpleCommandPrefix)操作完整复合字符串,会产生死规则如 Bash(cd src:*)
  • 解决方案:单规则 → 种子到可编辑输入;多规则 → yes-apply-suggestions 原子保存所有子命令规则

3.3 分类器自动审批的 UX 反馈

ClassifierCheckingSubtitle------独立组件隔离 20fps shimmer 时钟:

typescript 复制代码
// 隔离前:useShimmerAnimation 在 535 行的 BashPermissionRequestInner 内
// 每 50ms 时钟 tick 重渲染整个对话框(PermissionDialog + Select + 所有子组件)
// Inner 有 Compiler bailout,无法自动 memoize → 完整 JSX 树重建 20-60 次/分类器检查
function ClassifierCheckingSubtitle() {
  const [ref, glimmerIndex] = useShimmerAnimation("requesting", CHECKING_TEXT, false);
  return (
    <Box ref={ref}>
      <Text>{CHECKING_TEXT.split("").map((char, i) => 
        <ShimmerChar key={i} char={char} index={i} glimmerIndex={glimmerIndex} 
                     messageColor="inactive" shimmerColor="subtle" />
      )}</Text>
    </Box>
  );
}

三态显示

typescript 复制代码
const classifierSubtitle = feature('BASH_CLASSIFIER') 
  ? toolUseConfirm.classifierAutoApproved 
    ? <Text>
        <Text color="success">{figures.tick} Auto-approved</Text>
        {toolUseConfirm.classifierMatchedRule && 
          <Text dimColor>{' \u00b7 matched "'}{toolUseConfirm.classifierMatchedRule}{'"'}</Text>}
      </Text>
    : toolUseConfirm.classifierCheckInProgress 
      ? <ClassifierCheckingSubtitle />              // 等待中:shimmer 动画
      : classifierWasChecking 
        ? <Text dimColor>Requires manual approval</Text>  // 分类器完成但仍需人工
        : undefined                                   // 未触发分类器
  : undefined;

用户覆盖 :即使分类器自动通过(classifierAutoApproved=true),用户仍可按 Esc 关闭勾选标记:

typescript 复制代码
useKeybinding('confirm:no', handleDismissCheckmark, {
  context: 'Confirmation',
  isActive: feature('BASH_CLASSIFIER') ? !!toolUseConfirm.classifierAutoApproved : false
});

自动通过时选项变为禁用状态:

typescript 复制代码
<Select options={toolUseConfirm.classifierAutoApproved 
  ? options.map(o => ({ ...o, disabled: true })) 
  : options} 
  isDisabled={toolUseConfirm.classifierAutoApproved} ... />

3.4 破坏性命令警告

typescript 复制代码
const { destructiveWarning } = useMemo(() => {
  const destructiveWarning = getFeatureValue_CACHED_MAY_BE_STALE('tengu_destructive_command_warning', false)
    ? getDestructiveCommandWarning(command) 
    : null;
  return { destructiveWarning, ... };
}, [command, toolUseConfirm.input]);

// 渲染
{destructiveWarning_0 && (
  <Box marginBottom={1}>
    <Text color="warning" dimColor={toolUseConfirm.classifierAutoApproved}>
      {destructiveWarning_0}
    </Text>
  </Box>
)}

3.5 Sed 编辑特殊路由

typescript 复制代码
// BashPermissionRequest 入口处检测 sed -i 命令
const sedInfo = parseSedEditCommand(command);
if (sedInfo) {
  return <SedEditPermissionRequest sedInfo={sedInfo} ... />;
}
// 否则继续到 BashPermissionRequestInner

sed 命令不以原始命令展示,而是路由到 SedEditPermissionRequest 以 diff 形式展示变更------体现"展示用户关心的信息,而非技术细节"。


4. Diff 驱动的文件权限决策

4.1 FileEditPermissionRequest

源码位置FileEditPermissionRequest/FileEditPermissionRequest.tsx

typescript 复制代码
export function FileEditPermissionRequest(props) {
  const parsed = FileEditTool.inputSchema.parse(props.toolUseConfirm.input);
  const { file_path, old_string, new_string, replace_all } = parsed;
  
  return (
    <FilePermissionDialog
      toolUseConfirm={...}
      title="Edit file"
      subtitle={relative(getCwd(), file_path)}
      question={<Text>Do you want to make this edit to <Text bold>{basename(file_path)}</Text>?</Text>}
      content={<FileEditToolDiff file_path={file_path} edits={[{ old_string, new_string, replace_all }]} />}
      path={file_path}
      completionType="str_replace_single"
      parseInput={parseInput}
      ideDiffSupport={ideDiffSupport}  // IDE diff 集成
    />
  );
}

4.2 IDE Diff 集成

typescript 复制代码
const ideDiffSupport: IDEDiffSupport<FileEditInput> = {
  getConfig: (input) => createSingleEditDiffConfig(
    input.file_path, input.old_string, input.new_string, input.replace_all
  ),
  applyChanges: (input, modifiedEdits) => {
    // 用户在 IDE 中修改 Claude 的编辑后,更新 input
    const firstEdit = modifiedEdits[0];
    if (firstEdit) {
      return {
        ...input,
        old_string: firstEdit.old_string,
        new_string: firstEdit.new_string,
        replace_all: firstEdit.replace_all
      };
    }
    return input;
  }
};

设计意义:当用户在 VS Code/Cursor 等 IDE 中使用 Claude Code 时,文件编辑权限请求通过 RPC 自动在 IDE 中打开 diff 视图。用户可以在最熟悉的编辑器环境中审阅变更,甚至修改 Claude 的编辑后再接受------这让用户从"审批者"升级为"协作者"。

4.3 FilePermissionDialog Hook

源码位置FilePermissionDialog/useFilePermissionDialog.ts

typescript 复制代码
export function useFilePermissionDialog<T>({...}): UseFilePermissionDialogResult<T> {
  // 选项生成
  const options = useMemo(() => getFilePermissionOptions({
    filePath, toolPermissionContext, operationType, ...
  }), [filePath, toolPermissionContext, operationType, yesInputMode, noInputMode]);
  
  // 选项处理:使用共享的 PERMISSION_HANDLERS
  const onChange = useCallback((option, input, feedback?) => {
    // 关键:覆盖 onAllow 以传递修改后的 input(IDE diff 修改)
    const originalOnAllow = toolUseConfirm.onAllow;
    toolUseConfirm.onAllow = (_input, permissionUpdates, feedback) => {
      originalOnAllow(input, permissionUpdates, feedback);
    };
    
    const handler = PERMISSION_HANDLERS[option.type];
    handler(params, { feedback, hasFeedback, enteredFeedbackMode, scope });
  }, [...]);
  
  // confirm:cycleMode 快捷键:直接选择会话级允许
  const handleCycleMode = useCallback(() => {
    const sessionOption = options.find(o => o.option.type === 'accept-session');
    if (sessionOption) onChange(sessionOption.option, parsedInput);
  }, [options, parseInput, toolUseConfirm.input, onChange]);
  
  useKeybindings({ 'confirm:cycleMode': handleCycleMode }, { context: 'Confirmation' });
  
  return { options, onChange, acceptFeedback, rejectFeedback, ... };
}

5. FallbackPermissionRequest------通用回退

源码位置FallbackPermissionRequest.tsx

当工具没有专用权限组件时使用通用回退组件:

typescript 复制代码
export function FallbackPermissionRequest(t0) {
  const userFacingName = toolUseConfirm.tool.userFacingName(toolUseConfirm.input);
  // MCP 工具特殊处理:去除 " (MCP)" 后缀
  const cleanName = userFacingName.endsWith(" (MCP)") 
    ? userFacingName.slice(0, -6) 
    : userFacingName;
  
  const showAlwaysAllowOptions = shouldShowAlwaysAllowOptions();
  
  const options = [
    { label: "Yes", value: "yes", feedbackConfig: { type: "accept" } },
  ];
  
  if (showAlwaysAllowOptions) {
    options.push({
      label: <Text>Yes, and don't ask again for <Text bold>{cleanName}</Text> commands in <Text bold>{originalCwd}</Text></Text>,
      value: "yes-dont-ask-again"
    });
  }
  
  options.push({ label: "No", value: "no", feedbackConfig: { type: "reject" } });
  
  return (
    <PermissionDialog title="Tool use" workerBadge={workerBadge}>
      {/* 工具名 + 渲染消息 + 描述 */}
      <PermissionRuleExplanation permissionResult={...} toolType="tool" />
      <PermissionPrompt options={options} onSelect={handleSelect} onCancel={handleCancel} />
    </PermissionDialog>
  );
}

三个选项的处理

  • yesonAllow(input, [], feedback) --- 不创建持久规则
  • yes-dont-ask-againonAllow(input, [{ type: 'addRules', rules: [{ toolName }], behavior: 'allow', destination: 'localSettings' }]) --- 创建工具级 allow 规则
  • noonReject(feedback) --- 拒绝

6. AI 解释与透明度机制

6.1 Ctrl+E------AI 操作解释

源码位置PermissionExplanation.tsx

typescript 复制代码
export function usePermissionExplainerUI(props) {
  const enabled = isPermissionExplainerEnabled();
  const [visible, setVisible] = useState(false);
  const [promise, setPromise] = useState(null);
  
  // 懒加载:仅在用户按 Ctrl+E 时创建 AI 请求
  const toggle = () => {
    if (!visible) {
      logEvent("tengu_permission_explainer_shortcut_used", {});
      if (!promise) {
        setPromise(createExplanationPromise(props));  // 调用 Haiku 模型
      }
    }
    setVisible(!visible);
  };
  
  useKeybinding("confirm:toggleExplanation", toggle, { context: "Confirmation", isActive: enabled });
  
  return { visible, enabled, promise };
}

关键设计

  • 懒加载:仅在用户按 Ctrl+E 时才调用 AI,避免每次权限请求都消耗 token
  • Promise 复用:创建后缓存到 state,再次展开不重新请求
  • React 19 use() + Suspense:异步读取解释结果
typescript 复制代码
function ExplanationResult({ promise }) {
  const explanation = use(promise);  // React 19 use() hook
  
  return (
    <Box flexDirection="column" marginTop={1}>
      <Text>{explanation.explanation}</Text>
      <Box marginTop={1}><Text>{explanation.reasoning}</Text></Box>
      <Box marginTop={1}>
        <Text color={getRiskColor(explanation.riskLevel)}>
          {getRiskLabel(explanation.riskLevel)}:
        </Text>
        <Text> {explanation.risk}</Text>
      </Box>
    </Box>
  );
}

function getRiskColor(riskLevel: RiskLevel) {
  switch (riskLevel) {
    case 'LOW':    return 'success';   // 绿色
    case 'MEDIUM': return 'warning';   // 黄色
    case 'HIGH':   return 'error';     // 红色
  }
}

Shimmer 加载动画

typescript 复制代码
function ShimmerLoadingText() {
  const [ref, glimmerIndex] = useShimmerAnimation("responding", LOADING_MESSAGE, false);
  return (
    <Box ref={ref}>
      <Text>{LOADING_MESSAGE.split("").map((char, index) => 
        <ShimmerChar key={index} char={char} index={index} glimmerIndex={glimmerIndex}
                     messageColor="inactive" shimmerColor="text" />
      )}</Text>
    </Box>
  );
}

6.2 规则溯源展示

源码位置PermissionRuleExplanation.tsx

typescript 复制代码
function stringsForDecisionReason(reason, toolType): DecisionReasonStrings | null {
  switch (reason.type) {
    case 'rule':
      return {
        reasonString: `Permission rule ${chalk.bold(permissionRuleValueToString(reason.rule.ruleValue))} requires confirmation for this ${toolType}.`,
        configString: reason.rule.source === 'policySettings' ? undefined : '/permissions to update rules'
      };
    case 'hook':
      return {
        reasonString: `Hook ${chalk.bold(reason.hookName)} requires confirmation for this ${toolType}${hookReasonString}${sourceLabel}`,
        configString: '/hooks to update'
      };
    case 'safetyCheck':
    case 'other':
      return { reasonString: reason.reason, configString: undefined };
    case 'workingDir':
      return { reasonString: reason.reason, configString: '/permissions to update rules' };
    // ...
  }
}

设计意义 :不仅告诉用户"需要确认",还告诉"为什么需要确认"和"如何修改这个行为"。/permissions 配置提示直接指向修改入口。policySettings 来源的规则不显示配置提示(策略规则不可由用户修改)。

6.3 Ctrl+D------调试信息

源码位置PermissionDecisionDebugInfo.tsx

typescript 复制代码
export function PermissionDecisionDebugInfo({ permissionResult, toolName }) {
  const toolPermissionContext = useAppState(s => s.toolPermissionContext);
  const decisionReason = permissionResult.decisionReason;
  const suggestions = "suggestions" in permissionResult ? permissionResult.suggestions : undefined;
  
  // 阴影规则检测
  const unreachableRules = useMemo(() => {
    const all = detectUnreachableRules(toolPermissionContext, { sandboxAutoAllowEnabled });
    if (suggestedRules.length > 0) {
      return all.filter(u => suggestedRules.some(s => s.matches(u)));
    }
    if (toolName) return all.filter(u => u.rule.ruleValue.toolName === toolName);
    return all;
  }, [suggestions, toolName, toolPermissionContext]);
  
  return (
    <Box flexDirection="column">
      <Text>Behavior {permissionResult.behavior}</Text>
      {permissionResult.behavior !== "allow" && <Text>Message {permissionResult.message}</Text>}
      <Text>Reason <PermissionDecisionInfoItem decisionReason={decisionReason} /></Text>
      <SuggestionDisplay suggestions={suggestions} width={10} />
      {unreachableRules.length > 0 && (
        <Box marginTop={1}>
          <Text color="warning">⚠ Unreachable Rules ({unreachableRules.length})</Text>
          {unreachableRules.map(u => (
            <Box>
              <Text color="warning">{permissionRuleValueToString(u.rule.ruleValue)}</Text>
              <Text dimColor>{u.reason}</Text>
              <Text dimColor>Fix: {u.fix}</Text>
            </Box>
          ))}
        </Box>
      )}
    </Box>
  );
}

调试信息内容

  • Behavior:allow / ask / deny / passthrough
  • Message:决策消息(非 allow 时)
  • Reason:完整决策原因链(支持 subcommandResults 递归展示)
  • Suggestions:建议的规则/目录/模式
  • Unreachable Rules:阴影规则检测------被更高优先级规则遮蔽的规则

subcommandResults 递归展示

typescript 复制代码
case "subcommandResults":
  return Array.from(decisionReason.reasons.entries()).map(([subcommand, result]) => {
    const icon = result.behavior === "allow" ? ✓ : ✗;
    return (
      <Box>
        <Text>{icon} {subcommand}</Text>
        {result.decisionReason?.type !== "subcommandResults" && 
          <Text>⎿ {decisionReasonDisplayString(result.decisionReason)}</Text>}
        {result.behavior === "ask" && <SuggestedRules suggestions={result.suggestions} />}
      </Box>
    );
  });

7. Tab 键反馈:渐进式披露的典范

7.1 上下文敏感占位符

typescript 复制代码
// PermissionPrompt.tsx
const DEFAULT_PLACEHOLDERS: Record<FeedbackType, string> = {
  accept: 'tell Claude what to do next',       // 接受时:引导下一步
  reject: 'tell Claude what to do differently' // 拒绝时:引导替代方案
};

占位符的微妙措辞差异引导用户提供不同类型的反馈:接受时引导用户补充指令,拒绝时引导用户说明替代方案。

7.2 useShellPermissionFeedback Hook

源码位置useShellPermissionFeedback.ts

Bash 和 PowerShell 共享的反馈模式状态管理:

typescript 复制代码
export function useShellPermissionFeedback({toolUseConfirm, onDone, onReject, explainerVisible}) {
  const [rejectFeedback, setRejectFeedback] = useState('');
  const [acceptFeedback, setAcceptFeedback] = useState('');
  const [yesInputMode, setYesInputMode] = useState(false);
  const [noInputMode, setNoInputMode] = useState(false);
  const [focusedOption, setFocusedOption] = useState('yes');
  const [yesFeedbackModeEntered, setYesFeedbackModeEntered] = useState(false);  // 持久标记
  const [noFeedbackModeEntered, setNoFeedbackModeEntered] = useState(false);
  
  function handleInputModeToggle(option) {
    toolUseConfirm.onUserInteraction();  // 通知系统用户正在交互(阻止分类器自动关闭)
    // ... toggle input mode + log analytics
  }
  
  function handleReject(feedback?) {
    if (!hasFeedback) {
      logEvent('tengu_permission_request_escape', { explainer_visible: explainerVisible });
      setAppState(prev => ({ ...prev, attribution: { ...prev.attribution, escapeCount: prev.attribution.escapeCount + 1 }}));
    }
    logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'reject', hasFeedback);
    toolUseConfirm.onReject(trimmedFeedback || undefined);
    onReject();
    onDone();
  }
  
  function handleFocus(value) {
    if (value !== focusedOption) toolUseConfirm.onUserInteraction();  // 焦点变化时通知
    // 离开 Yes/No 时收起输入模式(仅当无文本时)
    if (value !== 'yes' && yesInputMode && !acceptFeedback.trim()) setYesInputMode(false);
    if (value !== 'no' && noInputMode && !rejectFeedback.trim()) setNoInputMode(false);
    setFocusedOption(value);
  }
  
  return { yesInputMode, noInputMode, ... };
}

关键设计细节

  • onUserInteraction() 在 Tab 切换和焦点变化时调用------这阻止异步分类器在用户正在交互时自动关闭对话框
  • feedbackModeEntered 标记是持久的------即使用户收起输入框再提交,仍记录其曾进入反馈模式(用于分析用户行为)
  • 空文本时自动收起输入模式------防止意外保留展开状态

8. 离席通知

源码位置PermissionRequest.tsx

typescript 复制代码
function getNotificationMessage(toolUseConfirm: ToolUseConfirm): string {
  const toolName = toolUseConfirm.tool.userFacingName(toolUseConfirm.input);
  if (toolUseConfirm.tool === ExitPlanModeV2Tool) {
    return 'Claude Code needs your approval for the plan';
  }
  if (toolUseConfirm.tool === EnterPlanModeTool) {
    return 'Claude Code wants to enter plan mode';
  }
  if (feature('REVIEW_ARTIFACT') && toolUseConfirm.tool === ReviewArtifactTool) {
    return 'Claude needs your approval for a review artifact';
  }
  if (!toolName || toolName.trim() === '') {
    return 'Claude Code needs your attention';
  }
  return `Claude needs your permission to use ${toolName}`;
}

// 在 PermissionRequest 组件中
useNotifyAfterTimeout(notificationMessage, "permission_prompt");

设计

  • 6 秒无用户交互时发送桌面通知(DEFAULT_INTERACTION_THRESHOLD_MS = 6000
  • 通知消息根据工具类型定制------Plan Mode 提示"needs your approval for the plan",其他工具提示"needs your permission to use {toolName}"
  • 用户即使在其他应用中也能快速判断是否需要立即回来

9. 多 Agent 场景的权限 UI

9.1 WorkerBadge

源码位置WorkerBadge.tsx

typescript 复制代码
export type WorkerBadgeProps = {
  name: string;   // Worker 名称
  color: string;  // Worker 颜色标识
};

export function WorkerBadge({ name, color }) {
  const inkColor = toInkColor(color);
  return (
    <Box flexDirection="row" gap={1}>
      <Text color={inkColor}>{BLACK_CIRCLE} <Text bold>@{name}</Text></Text>
    </Box>
  );
}

PermissionRequestTitle 中渲染:

typescript 复制代码
export function PermissionRequestTitle({ title, subtitle, color, workerBadge }) {
  return (
    <Box flexDirection="column">
      <Box flexDirection="row" gap={1}>
        <Text bold color={color}>{title}</Text>
        {workerBadge && <Text dimColor>{"· "}@{workerBadge.name}</Text>}
      </Box>
      {subtitle && <Text dimColor wrap="truncate-start">{subtitle}</Text>}
    </Box>
  );
}

当权限请求来自子 Agent 时,对话框标题区域显示 Worker 徽章:Bash command · @test-runner

9.2 WorkerPendingPermission

源码位置WorkerPendingPermission.tsx

Swarm Worker 端的等待指示器:

typescript 复制代码
export function WorkerPendingPermission({ toolName, description }) {
  const teamName = getTeamName();
  const agentName = getAgentName();
  const agentColor = getTeammateColor();
  
  return (
    <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
      <Box marginBottom={1}>
        <Spinner />
        <Text color="warning" bold> Waiting for team lead approval</Text>
      </Box>
      {agentName && agentColor && <WorkerBadge name={agentName} color={agentColor} />}
      <Text dimColor>Tool: </Text><Text>{toolName}</Text>
      <Text dimColor>Action: </Text><Text>{description}</Text>
      {teamName && <Text dimColor>Permission request sent to team "{teamName}" leader</Text>}
    </Box>
  );
}

Worker 端显示警告色边框 + Spinner + "Waiting for team lead approval"------让 Worker 用户知道权限请求已发出,等待 Leader 审批。


10. 权限请求日志

10.1 usePermissionRequestLogging

源码位置hooks.ts

typescript 复制代码
export function usePermissionRequestLogging(toolUseConfirm, unaryEvent) {
  const setAppState = useSetAppState();
  const loggedToolUseID = useRef<string | null>(null);  // 防止重复日志
  
  useEffect(() => {
    // 防止对象引用变化导致重复触发(会导致无限微任务循环)
    if (loggedToolUseID.current === toolUseConfirm.toolUseID) return;
    loggedToolUseID.current = toolUseConfirm.toolUseID;
    
    // 1. 增加权限提示计数(归因追踪)
    setAppState(prev => ({
      ...prev,
      attribution: { ...prev.attribution, permissionPromptCount: prev.attribution.permissionPromptCount + 1 }
    }));
    
    // 2. 分析事件
    logEvent('tengu_tool_use_show_permission_request', {
      messageID: toolUseConfirm.assistantMessage.message.id,
      toolName: sanitizeToolNameForAnalytics(toolUseConfirm.tool.name),
      isMcp: toolUseConfirm.tool.isMcp ?? false,
      decisionReasonType: toolUseConfirm.permissionResult.decisionReason?.type,
      sandboxEnabled: SandboxManager.isSandboxingEnabled(),
    });
    
    // 3. [ANT-ONLY] 无 always-allow 建议的权限请求
    if (process.env.USER_TYPE === 'ant') {
      if (toolUseConfirm.tool.name === BashTool.name && 
          permissionResult.behavior === 'ask' && 
          !hasRules(permissionResult.suggestions)) {
        logEvent('tengu_internal_tool_use_permission_request_no_always_allow', {...});
      }
    }
    
    // 4. [ANT-ONLY] Bash 命令详细日志
    if (process.env.USER_TYPE === 'ant') {
      // 记录 Bash 命令拆分 + 完整输入 + 决策原因
      logEvent('tengu_internal_bash_tool_use_permission_request', {...});
    }
    
    // 5. Unary 事件
    void logUnaryEvent({ completion_type: unaryEvent.completion_type, event: 'response', metadata: {...} });
  }, [toolUseConfirm, unaryEvent, setAppState]);
}

关键防御

  • loggedToolUseID ref 防止重复日志------注释说明"不防护会导致 setAppState 级联 → 无限微任务循环 → CPU 100% + 500MB/min 内存泄漏"
  • 组件按 toolUseID keyed,ref 在 remount 时重置

10.2 日志分层

事件名 触发条件 用途
tengu_tool_use_show_permission_request 每次显示权限请求 通用权限请求统计
tengu_internal_tool_use_permission_request_no_always_allow ant Bash 无 always-allow 建议 优化建议覆盖率
tengu_internal_bash_tool_use_permission_request ant Bash 命令详情 命令分类与减少不必要请求
tengu_permission_request_option_selected 用户选择选项 选项使用分布
tengu_accept_submitted 用户接受(带反馈上下文) 反馈使用率
tengu_reject_submitted 用户拒绝(带反馈上下文) 反馈使用率
tengu_accept_feedback_mode_entered/collapsed Tab 反馈模式切换 渐进式披露使用率
tengu_permission_request_escape Esc 取消 取消行为追踪
tengu_permission_explainer_shortcut_used Ctrl+E AI 解释 解释功能使用率

11. Shell 权限建议标签生成

源码位置shellPermissionHelpers.tsx

generateShellSuggestionsLabel 函数根据建议类型生成人类可读的标签:

typescript 复制代码
export function generateShellSuggestionsLabel(suggestions, shellToolName, commandTransform?) {
  const allRules = suggestions.filter(s => s.type === 'addRules').flatMap(s => s.rules || []);
  const readRules = allRules.filter(r => r.toolName === 'Read');
  const shellRules = allRules.filter(r => r.toolName === shellToolName);
  const directories = suggestions.filter(s => s.type === 'addDirectories').flatMap(s => s.directories || []);
  
  const shellCommands = [...new Set(shellRules.flatMap(rule => {
    const command = permissionRuleExtractPrefix(rule.ruleContent) ?? rule.ruleContent;
    return commandTransform ? commandTransform(command) : command;
  }))];
  
  // 单类型标签
  if (hasReadPaths && !hasDirectories && !hasCommands) {
    return `Yes, allow reading from {dirName}/ from this project`;
  }
  if (hasDirectories && !hasReadPaths && !hasCommands) {
    return `Yes, and always allow access to {dirName}/ from this project`;
  }
  if (hasCommands && !hasDirectories && !hasReadPaths) {
    return `Yes, and don't ask again for {commands} commands in {cwd}`;
  }
  
  // 混合类型标签
  if ((hasDirectories || hasReadPaths) && hasCommands) {
    return `Yes, and allow access to {paths} and {commands} commands`;
  }
  return null;
}

智能截断

typescript 复制代码
function commandListDisplayTruncated(commands: string[]): ReactNode {
  const plainText = commands.join(', ');
  if (plainText.length > 50) return 'similar';  // 过长时用 "similar" 替代
  return commandListDisplay(commands);
}

Bash 特殊处理stripBashRedirections 去除输出重定向,避免文件名显示为命令。


12. 源码 vs 原书对照

维度 原书描述 源码发现
三层架构 PermissionRequest / PermissionDialog / PermissionPrompt ✅ 一致,但源码中 PermissionPrompt 仅被 Fallback 使用,Bash/PowerShell 有独立的 Select 直接使用
Bash 四选项 Yes / Yes-apply-suggestions / Yes-prefix-edited / No 源码有第五选项 yes-classifier-reviewed(Feature Flag + ant 构建独有)
前缀提取 getSimpleCommandPrefix + BARE_SHELL_PREFIXES ✅ 一致,但源码还有 tree-sitter 异步精化 + 复合命令后端建议优先
Tab 反馈 上下文敏感占位符 + 空提交取消 ✅ 一致,源码还显示 onUserInteraction() 调用阻止分类器竞态
Ctrl+E 解释 Haiku 模型 + 三级风险着色 + shimmer 动画 ✅ 一致,源码使用 React 19 use() + Suspense 实现异步
Ctrl+D 调试 完整权限决策路径 源码额外包含 detectUnreachableRules 阴影规则检测
离席通知 6 秒阈值 + 定制消息 ✅ 一致
Worker Badge 标题区域显示 @name 源码有独立的 WorkerPendingPermission(Worker 端等待指示器)
Sed 编辑路由 路由到 SedEditPermissionRequest 以 diff 展示 ✅ 一致,在 BashPermissionRequest 入口处检测
IDE diff VS Code/Cursor 中打开 diff 视图 源码有完整的 IDEDiffSupport 接口(getConfig + applyChanges)
ClassifierCheckingSubtitle shimmer 提取到独立组件 ✅ 一致,注释详细说明提取原因(20fps 时钟隔离)
破坏性命令警告 未详细描述 源码:getDestructiveCommandWarning + Feature Flag tengu_destructive_command_warning
confirm:cycleMode 未提及 源码:文件权限专属快捷键,直接选择会话级允许选项

源码独有发现

  1. 第五选项 yes-classifier-reviewed:原书只描述四种选项,源码中 ant 构建有第五种------基于分类器描述的持久规则
  2. onUserInteraction 机制 :Tab 切换和焦点变化时通知系统,阻止分类器在用户交互时自动关闭对话框------这是 5.1-5.4 笔记中 interactiveHandler 四路竞速的 UI 端配合
  3. feedbackModeEntered 持久标记:即使用户收起输入框再提交,仍记录曾进入反馈模式------用于分析用户行为模式
  4. loggedToolUseID 防无限循环:权限请求日志 hook 必须防止对象引用变化导致的无限 setAppState 级联
  5. PermissionDecisionDebugInfo 的阴影规则检测 :Ctrl+D 不仅展示决策路径,还检测被遮蔽的规则------原书 5.10.3 描述的 detectUnreachableRules 在 UI 层的体现
  6. Feature Flag 双层门控feature('BASH_CLASSIFIER') + "external" === 'ant' 双重检查------外部构建连分类器选项都不显示
  7. React Compiler 缓存 :所有组件使用 _c() 编译器运行时进行手工 memoization,比 useMemo 更细粒度
  8. useFilePermissionDialogconfirm:cycleMode:文件权限专属快捷键,直接选择会话级允许------键盘快捷操作

13. 设计模式提炼

模式1:工具特定 UI(Tool-Specific UI)

问题:不同工具的风险谱和用户关注点完全不同,通用确认框会导致权限疲劳。

方案permissionComponentForTool 路由分发,每个工具有专用权限组件。

收益:Bash 展示命令+前缀规则,文件编辑展示 diff,网络请求展示 URL------用户在充分信息下做决策。

模式2:渐进式披露(Progressive Disclosure)

问题:高级功能(反馈输入)不应打扰初级用户。

方案:默认展示简洁选项,Tab 键展开反馈输入框,Ctrl+E 展开 AI 解释,Ctrl+D 展开调试信息。

收益:发现性较弱但不碍事,高级用户自然发现,初级用户不被打扰。

模式3:懒加载 AI(Lazy AI Loading)

问题:AI 解释消耗 token,不应每次权限请求都调用。

方案usePermissionExplainerUI 仅在用户按 Ctrl+E 时创建 Promise,且 Promise 缓存到 state 供后续展开复用。

收益:按需消耗,零浪费。

模式4:时钟隔离(Clock Isolation)

问题:20fps shimmer 动画会导致整个对话框每 50ms 重渲染。

方案ClassifierCheckingSubtitle 独立组件封装 useShimmerAnimation,隔离时钟 tick。

收益:避免 20-60 次/分类器检查的完整 JSX 树重建。

模式5:用户交互感知(User Interaction Awareness)

问题:异步分类器可能在用户正在交互时自动关闭对话框。

方案onUserInteraction() 在 Tab 切换、焦点变化时调用,通知 interactiveHandler 取消异步自动审批。

收益:用户意图优先于自动化------即使用户只是聚焦到某个选项,也不会被自动关闭打断。

模式6:可编辑建议(Editable Suggestion)

问题:后端生成的规则建议可能不完全符合用户意图。

方案yes-prefix-edited 选项预填充可编辑的前缀规则,用户可缩小(npm run build)、扩大(npm:*)或保持默认(npm run:*)。

收益:用户主动参与安全决策,而非被动接受二选一。

模式7:协作者审批(Collaborative Approval)

问题:审批者只能接受或拒绝,无法修改 AI 的编辑。

方案IDEDiffSupport 接口------getConfig 生成 diff 配置,applyChanges 将用户修改后的编辑回写到 input。

收益:用户从"审批者"升级为"协作者"------可以修改 Claude 的编辑后再接受。

模式8:防御性日志(Defensive Logging)

问题:React 对象引用变化可能导致 Effect 重复触发,引发无限循环。

方案loggedToolUseID ref 去重------组件按 toolUseID keyed,ref 在 remount 时重置,只需在同一实例内去重。

收益:避免 CPU 100% + 500MB/min 内存泄漏。


14. 组件依赖关系图

复制代码
PermissionRequest
  ├── useNotifyAfterTimeout (离席通知)
  ├── useKeybinding (app:interrupt)
  └── permissionComponentForTool(tool) → 
        ├── BashPermissionRequest
        │     ├── parseSedEditCommand → SedEditPermissionRequest (路由)
        │     ├── useShellPermissionFeedback (Tab 反馈状态)
        │     ├── usePermissionExplainerUI (Ctrl+E AI 解释)
        │     ├── bashToolUseOptions (选项生成)
        │     ├── ClassifierCheckingSubtitle (shimmer 隔离)
        │     ├── PermissionDialog (容器)
        │     │     └── PermissionRequestTitle (标题 + WorkerBadge)
        │     ├── PermissionRuleExplanation (规则溯源)
        │     ├── PermissionExplainerContent (AI 解释内容)
        │     ├── PermissionDecisionDebugInfo (Ctrl+D 调试)
        │     ├── Select (选项交互)
        │     └── usePermissionRequestLogging (日志)
        │
        ├── FileEditPermissionRequest
        │     ├── FileEditToolDiff (diff 展示)
        │     └── FilePermissionDialog
        │           ├── useFilePermissionDialog (状态管理)
        │           │     ├── getFilePermissionOptions (选项生成)
        │           │     ├── PERMISSION_HANDLERS (选项处理)
        │           │     └── useKeybindings (confirm:cycleMode)
        │           ├── permissionOptions (选项定义)
        │           ├── usePermissionHandler (处理逻辑)
        │           └── ideDiffConfig (IDE diff 集成)
        │
        ├── FallbackPermissionRequest
        │     ├── PermissionDialog (容器)
        │     ├── PermissionRuleExplanation (规则溯源)
        │     └── PermissionPrompt (通用选项+反馈)
        │           ├── Select (选项交互)
        │           └── useKeybindings (快捷键)
        │
        └── ... (其他工具特定组件)

15. 总结

Claude Code 的权限 UX 设计围绕"渐进式信任"展开,通过三层组件架构(路由→容器→交互)实现了关注点分离,通过工具特定 UI 避免了权限疲劳。核心设计亮点包括:

  1. Bash 可编辑前缀规则------让用户主动参与安全决策
  2. Tab 键渐进式披露------高级功能不碍事但可发现
  3. Ctrl+E 懒加载 AI 解释------按需消耗 token
  4. ClassifierCheckingSubtitle 时钟隔离------性能感知影响 UX
  5. onUserInteraction 用户交互感知------用户意图优先于自动化
  6. IDEDiffSupport 协作者审批------从审批者到协作者
  7. WorkerBadge 多 Agent 标识------子 Agent 权限透明化
  8. loggedToolUseID 防御性日志------防止无限循环

这些设计共同实现了"按需打断、记住偏好、透明可控"三大原则,使权限系统既安全又不令人沮丧。

相关推荐
imperialeast1 天前
WinAXP音乐播放器8月4日更新
windows·算法·ui
元岳数字人小元1 天前
易部署易运维!AI数字人一体机实现场景长效运营
运维·人工智能·人机交互·交互·源代码管理
带娃的IT创业者2 天前
Puppeteer 深度解析:超越自动化测试的现代 Web 交互范式
前端·交互·puppeteer·可观测性·浏览器自动化·无头浏览器·web工程化
天天进步20153 天前
UI-TARS 源码解析 #14:parse_action_to_structure_output:从 Thought/Action 文本到动作字典
ui
_ZHOURUI_H_3 天前
Unity MyFramework 用法说明(二十五):使用 AtlasManager 统一管理图集与 Sprite 引用
ui·unity·游戏引擎·unity3d·游戏开发
不如摸鱼去3 天前
Wot UI 2.3.0 发布:二维码组件来了,Open Wot 与 wot-starter 同步更新
前端·ui·微信小程序·前端框架·uni-app
时空节拍AI数字人3 天前
多模态交互数字人:语音+视觉+触控如何融合
人工智能·microsoft·ai·aigc·交互·语音识别
quanjui3 天前
【智能体从对话到决策】虚拟环境下大语言模型的部署与智能体交互研究
人工智能·语言模型·交互