背景在 Claude Code 中AI 模型并非只能被动等待用户输入。当 AI 在推理过程中发现信息不完整、需要用户做出选择或确认时它能够主动向用户提问——在终端中渲染出带有选项的交互式 UI单选、多选、文本输入等待用户操作后继续执行。这个能力背后的实现本质上是一个Tool Calling Permission 中断 React 组件映射的三方协作系统。整体架构┌─────────────────────────────────────────────────────────┐ │ AI 模型推理层 │ │ AI 判断需要用户输入 → 调用 AskUserQuestion 工具 │ │ 输出 tool_use JSON符合预定义 schema │ └──────────────────────┬──────────────────────────────────┘ │ tool_use JSON ▼ ┌─────────────────────────────────────────────────────────┐ │ Permission 拦截层 │ │ checkPermissions() → behavior: ask │ │ 创建 ToolUseConfirm 对象 → 推入权限队列 → 暂停执行 │ └──────────────────────┬──────────────────────────────────┘ │ ToolUseConfirm 对象 ▼ ┌─────────────────────────────────────────────────────────┐ │ UI 渲染层React/Ink │ │ PermissionRequest 组件 → switch(tool) 映射 │ │ → AskUserQuestionPermissionRequest 渲染交互式 UI │ │ Select / SelectMulti / TextInput / Preview │ └──────────────────────┬──────────────────────────────────┘ │ 用户选择 → onAllow(answers) ▼ ┌─────────────────────────────────────────────────────────┐ │ 结果回传层 │ │ answers → updatedInput → tool.call() 执行 │ │ → tool_result 返回给 AI → AI 继续推理 │ └─────────────────────────────────────────────────────────┘第一层Tool 定义——AI 侧的提问协议核心文件src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx工具注册每个工具通过buildTool()注册提供name、inputSchema、outputSchema、checkPermissions等钩子。AI 模型在推理时看到的工具列表中就包含了AskUserQuestionexportconstAskUserQuestionTool:ToolInputSchema,OutputbuildTool({name:ASK_USER_QUESTION_TOOL_NAME,// AskUserQuestionsearchHint:prompt the user with a multiple-choice question,maxResultSizeChars:100_000,shouldDefer:true,asyncdescription(){returnDESCRIPTION},asyncprompt(){constformatgetQuestionPreviewFormat()if(formatundefined){returnASK_USER_QUESTION_TOOL_PROMPT}returnASK_USER_QUESTION_TOOL_PROMPTPREVIEW_FEATURE_PROMPT[format]},getinputSchema():InputSchema{returninputSchema()},// ... 其他钩子})Input Schema——AI 必须遵守的结构化协议工具的inputSchema定义了 AI 输出时必须遵守的 JSON 结构。这是整个机制的数据契约constquestionOptionSchemalazySchema(()z.object({label:z.string().describe(The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.),description:z.string().describe(Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.),preview:z.string().optional().describe(Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons.)}))constquestionSchemalazySchema(()z.object({question:z.string().describe(The complete question to ask the user. Should be clear, specific, and end with a question mark.),header:z.string().describe(Very short label displayed as a chip/tag (max 12 chars). Examples: Auth method, Library, Approach.),options:z.array(questionOptionSchema()).min(2).max(4).describe(The available choices for this question. Must have 2-4 options. There should be no Other option, that will be provided automatically.),multiSelect:z.boolean().default(false).describe(Set to true to allow the user to select multiple options instead of just one.)}))constinputSchemalazySchema(()z.strictObject({questions:z.array(questionSchema()).min(1).max(4).describe(Questions to ask the user (1-4 questions)),answers:z.record(z.string(),z.string()).optional().describe(User answers collected by the permission component),annotations:annotationsSchema(),metadata:z.object({source:z.string().optional()}).optional(),}).refine(UNIQUENESS_REFINE.check,{message:UNIQUENESS_REFINE.message,}))设计要点限制 1-4 个问题每个问题 2-4 个选项——防止 AI 滥用自动提供 “Other” 选项——用户始终可以自由输入schema 中的.describe()文本就是给 AI 模型的使用指南refine校验确保问题文本和选项标签的唯一性AI 实际输出的 JSON 示例当 AI 决定需要提问时它会输出这样的 tool_use{type:tool_use,name:AskUserQuestion,input:{questions:[{question:Which library should we use for date formatting?,header:Library,options:[{label:date-fns,description:Lightweight, tree-shakeable, functional API},{label:dayjs,description:Moment.js compatible, small bundle size},{label:Temporal,description:Modern native API, no dependencies needed}],multiSelect:false}]},id:toolu_01ABC123}第二层Permission 拦截——工具执行的红绿灯核心文件src/utils/permissions/permissions.ts— 权限检查入口src/hooks/toolPermission/handlers/interactiveHandler.ts— 交互式权限处理src/components/permissions/PermissionRequest.tsx— UI 路由组件checkPermissions——固定的必须询问策略AskUserQuestionTool的checkPermissions方法始终返回behavior: ask表示这个工具必须经过用户确认才能执行asynccheckPermissions(input){return{behavior:askasconst,message:Answer questions?,updatedInput:input,}}还有一个关键标记——requiresUserInteractionrequiresUserInteraction(){returntrue}权限处理流程当behavior ask时系统进入interactiveHandler创建一个ToolUseConfirm对象并推入权限队列tool_use 到达 ↓ checkPermissions() → { behavior: ask } ↓ interactiveHandler 创建 ToolUseConfirm ↓ ctx.pushToQueue(toolUseConfirm) ← 推入队列暂停工具执行 ↓ 等待用户操作onAllow / onRejectToolUseConfirm——连接 AI 和 UI 的桥梁ToolUseConfirm是一个携带完整上下文的对象定义在PermissionRequest.tsxexporttypeToolUseConfirmInputextendsAnyObjectAnyObject{assistantMessage:AssistantMessage;// AI 的原始消息tool:ToolInput;// 工具实例用于 switch 映射description:string;// 工具描述input:z.inferInput;// AI 输出的结构化数据questions 等toolUseContext:ToolUseContext;// 工具执行上下文toolUseID:string;// 工具调用 IDpermissionResult:PermissionDecision;// 权限决策结果// 回调函数——用户操作后触发onAllow(updatedInput,permissionUpdates,feedback?,contentBlocks?):void;onReject(feedback?,contentBlocks?):void;recheckPermission():Promisevoid;}这个对象是连接后端工具执行和前端UI 渲染的桥梁。AI 的结构化数据通过input字段传递给 UI用户的操作通过onAllow/onReject回传。组件路由——switch-case 映射PermissionRequest组件维护了一个工具到 UI 组件的映射表functionpermissionComponentForTool(tool:Tool):React.ComponentTypePermissionRequestProps{switch(tool){caseFileEditTool:returnFileEditPermissionRequestcaseFileWriteTool:returnFileWritePermissionRequestcaseBashTool:returnBashPermissionRequestcaseAskUserQuestionTool:returnAskUserQuestionPermissionRequest// ← 这里caseReviewArtifactTool:returnReviewArtifactPermissionRequest??FallbackPermissionRequest// ... 其他工具default:returnFallbackPermissionRequest}}设计选择没有使用通用的 schema→UI 渲染引擎而是每个工具一个专用组件。这样每个 UI 可以针对自己的交互场景做深度优化比如 Bash 工具显示命令预览FileEdit 工具显示 diff。第三层UI 渲染——React/Ink 交互式组件核心文件src/components/permissions/AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.tsxsrc/components/permissions/AskUserQuestionPermissionRequest/QuestionView.tsxsrc/components/permissions/AskUserQuestionPermissionRequest/use-multiple-choice-state.tssrc/components/CustomSelect/select.tsx组件层级AskUserQuestionPermissionRequest ← 入口组件 ├─ AskUserQuestionPermissionRequestBody ← 解析 input管理状态 │ ├─ QuestionView ← 单个问题渲染 │ │ ├─ Select / SelectMulti ← 选项控件 │ │ ├─ PreviewQuestionView ← 预览面板如果有 preview │ │ └─ TextInput ← Other 自定义输入 │ └─ SubmitQuestionsView ← 多问题时的提交确认页 └─ useMultipleChoiceState() ← 状态管理 hook状态管理——useReducer 管理复杂交互use-multiple-choice-state.ts使用useReducer管理多问题场景下的状态流转typeQuestionState{selectedValue?:string|string[]// 选中的选项单选string多选string[]textInputValue:string// 自定义文本输入}typeState{currentQuestionIndex:number// 当前显示第几个问题answers:Recordstring,AnswerValue// 已收集的回答question→answerquestionStates:Recordstring,QuestionState// 每个问题的 UI 状态isInTextInput:boolean// 是否正在文本输入模式}typeAction|{type:next-question}// 下一题|{type:prev-question}// 上一题|{type:update-question-state;// 更新问题 UI 状态questionText:string;updates:PartialQuestionState;isMultiSelect:boolean}|{type:set-answer;// 设置答案questionText:string;answer:string;shouldAdvance:boolean}|{type:set-text-input-mode;// 切换文本输入模式isInInput:boolean}functionreducer(state:State,action:Action):State{switch(action.type){casenext-question:return{...state,currentQuestionIndex:state.currentQuestionIndex1,isInTextInput:false}caseprev-question:return{...state,currentQuestionIndex:Math.max(0,state.currentQuestionIndex-1),isInTextInput:false}caseset-answer:{constnewState{...state,answers:{...state.answers,[action.questionText]:action.answer}}if(action.shouldAdvance){return{...newState,currentQuestionIndex:newState.currentQuestionIndex1,isInTextInput:false}}returnnewState}// ...}}UI 控件映射规则QuestionView.tsx根据 schema 中的字段决定渲染什么控件Schema 字段渲染的 UI 控件说明multiSelect: falseSelect组件单选方向键导航Enter 确认multiSelect: trueSelectMulti组件多选空格切换Enter 提交始终存在“Other” TextInput自动追加的自由输入选项preview有值PreviewQuestionView左右分栏右侧显示预览内容多个问题QuestionNavigationBar问题导航栏 提交确认页入口组件——解析 input 并初始化渲染AskUserQuestionPermissionRequestBody是核心渲染组件它用inputSchema.safeParse()解析 AI 输出的 JSON提取questions数组调用useMultipleChoiceState()初始化状态根据currentQuestionIndex决定渲染问题还是提交页functionAskUserQuestionPermissionRequestBody(t0){const{toolUseConfirm,onDone,onReject,highlight}t0// 解析 AI 输出的结构化数据constresultAskUserQuestionTool.inputSchema.safeParse(toolUseConfirm.input)constquestionsresult.success?result.data.questions||[]:[]// 初始化多选状态conststateuseMultipleChoiceState()const{currentQuestionIndex,answers,questionStates,isInTextInput,nextQuestion,prevQuestion,updateQuestionState,setAnswer,setTextInputMode}state// 当前问题 or 提交页constcurrentQuestioncurrentQuestionIndexquestions.length?questions[currentQuestionIndex]:nullconstisInSubmitViewcurrentQuestionIndexquestions.length// 单问题单选时隐藏提交页consthideSubmitTabquestions.length1!questions[0]?.multiSelect// 是否所有问题都已回答constallQuestionsAnsweredquestions.every(qq?.question!!answers[q.question])// ... 渲染逻辑}第四层结果回传——用户选择如何回到 AI核心文件src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx提交流程用户点击确认后submitAnswers()收集所有回答构建updatedInput调用toolUseConfirm.onAllow(updatedInput, [], undefined, contentBlocks)Permission 系统将updatedInput传递给tool.call()tool.call——透传数据call方法不做处理直接返回数据asynccall({questions,answers{},annotations},_context){return{data:{questions,answers,...(annotations{annotations})}}}mapToolResultToToolResultBlockParam——格式化回传这个方法将用户的回答格式化为 AI 可以理解的tool_resultmapToolResultToToolResultBlockParam({answers,annotations},toolUseID){constanswersTextObject.entries(answers).map(([questionText,answer]){constannotationannotations?.[questionText]constparts[${questionText}${answer}]if(annotation?.preview){parts.push(selected preview:\n${annotation.preview})}if(annotation?.notes){parts.push(user notes:${annotation.notes})}returnparts.join( )}).join(, )return{type:tool_result,content:User has answered your questions:${answersText}.You can now continue with the users answers in mind.,tool_use_id:toolUseID,}}AI 收到这个tool_result后就能读取用户的回答继续推理。完整端到端流程用户提问帮我选一个日期库 ↓ AI 开始推理 ↓ AI我需要问用户选哪个库 → 生成 tool_use: { name: AskUserQuestion, input: { questions: [{ question: 选哪个?, options: [...] }] } } ↓ Tool Calling 框架接收 tool_use ↓ checkPermissions() → { behavior: ask } ↓ interactiveHandler 创建 ToolUseConfirm 推入权限队列 → 暂停工具执行 ↓ PermissionRequest 组件 switch 映射 → AskUserQuestionPermissionRequest ↓ 解析 input.questions → 渲染: ┌─────────────────────────────────────┐ │ Library │ │ ○ date-fns Lightweight... │ │ ○ dayjs Moment compatible... │ │ ○ Temporal Modern native... │ │ ○ Other [输入框] │ └─────────────────────────────────────┘ ↓ 用户选择 dayjs → onAllow(answers) ↓ tool.call({ answers: { 选哪个?: dayjs } }) ↓ → tool_result: User has answered your questions: 选哪个?dayjs. You can now continue with the users answers in mind. ↓ AI 读取回答 → 好的使用 dayjs开始编写代码...设计模式总结1. 声明式交互协议AI 不是随意输出结构化数据而是调用预定义的工具。工具的inputSchemaZod同时服务于两个目的约束 AI 输出模型必须生成符合 schema 的 JSON指导 UI 渲染前端根据 schema 中的字段决定渲染什么控件2. Permission 中断模式工具执行不是直通的而是经过权限检查的中断层。behavior: ask的工具会暂停执行等待用户响应后才继续。这种模式统一处理了所有需要用户介入的场景——不只是提问还包括 Bash 命令确认、文件编辑确认等。3. 专用组件 vs 通用渲染Claude Code 没有采用通用 schema→UI 渲染引擎的方案而是每个工具一个专用 Permission 组件。好处是每个交互场景可以做深度优化Bash 显示命令预览FileEdit 显示 diffAskUserQuestion 显示选项代价是扩展新工具时需要同时写后端逻辑和前端组件。4. 可复用的状态管理useMultipleChoiceState是一个独立的 reducer hook封装了多问题导航、选项切换、文本输入等通用交互逻辑。它不耦合于任何特定组件可以被其他需要类似交互的工具复用。关键源码文件索引文件路径职责src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx工具定义、schema、权限检查、结果格式化src/tools/AskUserQuestionTool/prompt.ts给 AI 模型的工具使用指南src/utils/permissions/permissions.ts权限检查入口src/hooks/toolPermission/handlers/interactiveHandler.ts交互式权限处理src/components/permissions/PermissionRequest.tsx工具→UI 组件路由switch-casesrc/components/permissions/AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.tsx提问 UI 入口组件src/components/permissions/AskUserQuestionPermissionRequest/QuestionView.tsx单个问题渲染Select/SelectMulti/TextInputsrc/components/permissions/AskUserQuestionPermissionRequest/use-multiple-choice-state.ts多问题状态管理 reducersrc/components/CustomSelect/select.tsx底层选择控件