背景
在 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 模型在推理时看到的工具列表中,就包含了 AskUserQuestion:
typescript
export const AskUserQuestionTool: Tool<InputSchema, Output> = buildTool({
name: ASK_USER_QUESTION_TOOL_NAME, // "AskUserQuestion"
searchHint: 'prompt the user with a multiple-choice question',
maxResultSizeChars: 100_000,
shouldDefer: true,
async description() {
return DESCRIPTION
},
async prompt() {
const format = getQuestionPreviewFormat()
if (format === undefined) {
return ASK_USER_QUESTION_TOOL_PROMPT
}
return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format]
},
get inputSchema(): InputSchema {
return inputSchema()
},
// ... 其他钩子
})
Input Schema------AI 必须遵守的结构化协议
工具的 inputSchema 定义了 AI 输出时必须遵守的 JSON 结构。这是整个机制的数据契约:
typescript
const questionOptionSchema = lazySchema(() => 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.'
)
}))
const questionSchema = lazySchema(() => 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.'
)
}))
const inputSchema = lazySchema(() =>
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:
json
{
"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',表示这个工具 必须 经过用户确认才能执行:
typescript
async checkPermissions(input) {
return {
behavior: 'ask' as const,
message: 'Answer questions?',
updatedInput: input,
}
}
还有一个关键标记------requiresUserInteraction:
typescript
requiresUserInteraction() {
return true
}
权限处理流程
当 behavior === 'ask' 时,系统进入 interactiveHandler,创建一个 ToolUseConfirm 对象并推入权限队列:
tool_use 到达
↓
checkPermissions() → { behavior: 'ask' }
↓
interactiveHandler 创建 ToolUseConfirm
↓
ctx.pushToQueue(toolUseConfirm) ← 推入队列,暂停工具执行
↓
等待用户操作(onAllow / onReject)
ToolUseConfirm------连接 AI 和 UI 的桥梁
ToolUseConfirm 是一个携带完整上下文的对象(定义在 PermissionRequest.tsx):
typescript
export type ToolUseConfirm<Input extends AnyObject = AnyObject> = {
assistantMessage: AssistantMessage; // AI 的原始消息
tool: Tool<Input>; // 工具实例(用于 switch 映射)
description: string; // 工具描述
input: z.infer<Input>; // AI 输出的结构化数据(questions 等)
toolUseContext: ToolUseContext; // 工具执行上下文
toolUseID: string; // 工具调用 ID
permissionResult: PermissionDecision; // 权限决策结果
// 回调函数------用户操作后触发
onAllow(updatedInput, permissionUpdates, feedback?, contentBlocks?): void;
onReject(feedback?, contentBlocks?): void;
recheckPermission(): Promise<void>;
}
这个对象是连接后端(工具执行)和前端(UI 渲染)的桥梁。AI 的结构化数据通过 input 字段传递给 UI,用户的操作通过 onAllow / onReject 回传。
组件路由------switch-case 映射
PermissionRequest 组件维护了一个工具到 UI 组件的映射表:
typescript
function permissionComponentForTool(tool: Tool):
React.ComponentType<PermissionRequestProps>
{
switch (tool) {
case FileEditTool:
return FileEditPermissionRequest
case FileWriteTool:
return FileWritePermissionRequest
case BashTool:
return BashPermissionRequest
case AskUserQuestionTool:
return AskUserQuestionPermissionRequest // ← 这里
case ReviewArtifactTool:
return ReviewArtifactPermissionRequest ?? FallbackPermissionRequest
// ... 其他工具
default:
return FallbackPermissionRequest
}
}
设计选择:没有使用通用的 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 管理多问题场景下的状态流转:
typescript
type QuestionState = {
selectedValue?: string | string[] // 选中的选项(单选=string,多选=string[])
textInputValue: string // 自定义文本输入
}
type State = {
currentQuestionIndex: number // 当前显示第几个问题
answers: Record<string, AnswerValue> // 已收集的回答(question→answer)
questionStates: Record<string, QuestionState> // 每个问题的 UI 状态
isInTextInput: boolean // 是否正在文本输入模式
}
type Action =
| { type: 'next-question' } // 下一题
| { type: 'prev-question' } // 上一题
| { type: 'update-question-state'; // 更新问题 UI 状态
questionText: string;
updates: Partial<QuestionState>;
isMultiSelect: boolean }
| { type: 'set-answer'; // 设置答案
questionText: string;
answer: string;
shouldAdvance: boolean }
| { type: 'set-text-input-mode'; // 切换文本输入模式
isInInput: boolean }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'next-question':
return { ...state,
currentQuestionIndex: state.currentQuestionIndex + 1,
isInTextInput: false }
case 'prev-question':
return { ...state,
currentQuestionIndex: Math.max(0, state.currentQuestionIndex - 1),
isInTextInput: false }
case 'set-answer': {
const newState = {
...state,
answers: { ...state.answers,
[action.questionText]: action.answer }
}
if (action.shouldAdvance) {
return { ...newState,
currentQuestionIndex: newState.currentQuestionIndex + 1,
isInTextInput: false }
}
return newState
}
// ...
}
}
UI 控件映射规则
QuestionView.tsx 根据 schema 中的字段决定渲染什么控件:
| Schema 字段 | 渲染的 UI 控件 | 说明 |
|---|---|---|
multiSelect: false |
Select 组件 |
单选,方向键导航,Enter 确认 |
multiSelect: true |
SelectMulti 组件 |
多选,空格切换,Enter 提交 |
| 始终存在 | "Other" TextInput | 自动追加的自由输入选项 |
preview 有值 |
PreviewQuestionView |
左右分栏,右侧显示预览内容 |
| 多个问题 | QuestionNavigationBar |
问题导航栏 + 提交确认页 |
入口组件------解析 input 并初始化渲染
AskUserQuestionPermissionRequestBody 是核心渲染组件,它:
- 用
inputSchema.safeParse()解析 AI 输出的 JSON - 提取
questions数组 - 调用
useMultipleChoiceState()初始化状态 - 根据
currentQuestionIndex决定渲染问题还是提交页
typescript
function AskUserQuestionPermissionRequestBody(t0) {
const { toolUseConfirm, onDone, onReject, highlight } = t0
// 解析 AI 输出的结构化数据
const result = AskUserQuestionTool.inputSchema.safeParse(toolUseConfirm.input)
const questions = result.success ? result.data.questions || [] : []
// 初始化多选状态
const state = useMultipleChoiceState()
const {
currentQuestionIndex, answers, questionStates,
isInTextInput, nextQuestion, prevQuestion,
updateQuestionState, setAnswer, setTextInputMode
} = state
// 当前问题 or 提交页
const currentQuestion = currentQuestionIndex < questions.length
? questions[currentQuestionIndex]
: null
const isInSubmitView = currentQuestionIndex === questions.length
// 单问题单选时隐藏提交页
const hideSubmitTab = questions.length === 1 && !questions[0]?.multiSelect
// 是否所有问题都已回答
const allQuestionsAnswered =
questions.every(q => q?.question && !!answers[q.question])
// ... 渲染逻辑
}
第四层:结果回传------用户选择如何回到 AI
核心文件
src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx
提交流程
用户点击确认后:
submitAnswers()收集所有回答,构建updatedInput- 调用
toolUseConfirm.onAllow(updatedInput, [], undefined, contentBlocks) - Permission 系统将
updatedInput传递给tool.call()
tool.call------透传数据
call 方法不做处理,直接返回数据:
typescript
async call({ questions, answers = {}, annotations }, _context) {
return {
data: { questions, answers,
...(annotations && { annotations }) }
}
}
mapToolResultToToolResultBlockParam------格式化回传
这个方法将用户的回答格式化为 AI 可以理解的 tool_result:
typescript
mapToolResultToToolResultBlockParam({ answers, annotations }, toolUseID) {
const answersText = Object.entries(answers)
.map(([questionText, answer]) => {
const annotation = annotations?.[questionText]
const parts = [`"${questionText}"="${answer}"`]
if (annotation?.preview) {
parts.push(`selected preview:\n${annotation.preview}`)
}
if (annotation?.notes) {
parts.push(`user notes: ${annotation.notes}`)
}
return parts.join(' ')
})
.join(', ')
return {
type: 'tool_result',
content: `User has answered your questions: ${answersText}. ` +
`You can now continue with the user's 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 user's answers in mind."
↓
AI 读取回答 → "好的,使用 dayjs,开始编写代码..."
设计模式总结
1. 声明式交互协议
AI 不是随意输出结构化数据,而是调用预定义的工具。工具的 inputSchema(Zod)同时服务于两个目的:
- 约束 AI 输出:模型必须生成符合 schema 的 JSON
- 指导 UI 渲染:前端根据 schema 中的字段决定渲染什么控件
2. Permission 中断模式
工具执行不是"直通"的,而是经过权限检查的中断层。behavior: 'ask' 的工具会暂停执行,等待用户响应后才继续。这种模式统一处理了所有需要用户介入的场景------不只是提问,还包括 Bash 命令确认、文件编辑确认等。
3. 专用组件 vs 通用渲染
Claude Code 没有采用"通用 schema→UI 渲染引擎"的方案,而是每个工具一个专用 Permission 组件。好处是每个交互场景可以做深度优化(Bash 显示命令预览,FileEdit 显示 diff,AskUserQuestion 显示选项),代价是扩展新工具时需要同时写后端逻辑和前端组件。
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-case) |
src/components/permissions/AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.tsx |
提问 UI 入口组件 |
src/components/permissions/AskUserQuestionPermissionRequest/QuestionView.tsx |
单个问题渲染(Select/SelectMulti/TextInput) |
src/components/permissions/AskUserQuestionPermissionRequest/use-multiple-choice-state.ts |
多问题状态管理 reducer |
src/components/CustomSelect/select.tsx |
底层选择控件 |