对话框与 UI
Claude Code 如何使用 React 和 Ink 完全在终端内呈现权限请求、设置屏幕和交互式对话框。
Claude Code 没有浏览器窗口。 每个交互式 UI 元素(从 bash 批准提示到多步骤安装向导)都通过以下方式在终端中呈现: 反应+墨水。 本课程跟踪如何启动对话框、如何组成设计系统原语、如何将权限请求连接到特定工具以及向导模式如何允许多步骤流程。
dialogLaunchers.tsx →
interactiveHelpers.tsx →
components/design-system/Dialog.tsx →
components/design-system/Pane.tsx →
components/permissions/PermissionRequest.tsx →
components/permissions/PermissionDialog.tsx →
components/permissions/PermissionPrompt.tsx →
components/wizard/ →
components/CustomSelect/select.tsx →
components/Onboarding.tsx
整个UI系统可以理解为嵌套的四层:
Launchers
dialogLaunchers.tsx — 动态导入组件并在用户完成后解析 Promise 的异步函数
Helpers
interactiveHelpers.tsx — showDialog / showSetupDialog 包裹呈现在 AppStateProvider + KeybindingSetup
设计系统
Dialog, Pane, PermissionDialog - 固执己见的墨水包装器,用于一致的镀铬和键绑定
功能组件
每个工具的权限请求、入门步骤、向导页面 - 每个都由第 3 层原语组成
dialogLaunchers.tsx
在该文件存在之前,每个对话框都直接内联在内部 main.tsx。 提取是为了保留 main.tsx 更精简并启用代码分割:每个启动器
动态导入 仅在需要时才使用其组件,因此 JS 可以是:
TeleportResumeWrapper 正常启动时不会被解析。
每个启动器的模式都是相同的:调用 showSetupDialog (from
interactiveHelpers.tsx),传递一个渲染工厂来连接 done
回调到组件的 onComplete / onCancel props,并返回一个类型化的 Promise。
// dialogLaunchers.tsx — SnapshotUpdateDialog launcher
export async function launchSnapshotUpdateDialog(
root: Root,
props: { agentType: string; scope: AgentMemoryScope; snapshotTimestamp: string }
): Promise<'merge' | 'keep' | 'replace'> {
const { SnapshotUpdateDialog } = await import('./components/agents/SnapshotUpdateDialog.js');
return showSetupDialog<'merge' | 'keep' | 'replace'>(root, done =>
<SnapshotUpdateDialog
agentType={props.agentType}
scope={props.scope}
snapshotTimestamp={props.snapshotTimestamp}
onComplete={done}
onCancel={() => done('keep')}
/>
);
}
调用者得到一个简单的 Promise。 它等待它并根据键入的结果进行分支。 没有事件侦听器,没有全局状态 - 对话框生命周期完全表达为异步函数调用。
showSetupDialog。 简历对话选择器使用
renderAndRun 相反,因为它需要安装完整的 <App>
树(包括 FPS 跟踪、统计数据和 KeybindingSetup)而不是一个纯粹的对话框。 源代码中的注释明确指出这是为了“保留 getWorktreePaths 和导入之间的原始 Promise.all 并行性”。
一款发射器与众不同: launchAssistantInstallWizard 将两个 Promise 封装在一个 Promise.race。 如果安装抛出错误,则竞赛会拒绝,从而允许调用者区分 用户取消 (决定 null) 从
安装失败 (拒绝与 Error).
// dialogLaunchers.tsx — error vs cancel distinction
let rejectWithError: (reason: Error) => void;
const errorPromise = new Promise<never>((_, reject) => {
rejectWithError = reject;
});
const resultPromise = showSetupDialog<string | null>(root, done =>
<NewInstallWizard
defaultDir={defaultDir}
onInstalled={dir => done(dir)}
onCancel={() => done(null)}
onError={message => rejectWithError(new Error(`Installation failed: ${message}`))}
/>
);
return Promise.race([resultPromise, errorPromise]);
interactiveHelpers.tsx该文件提供了所有对话机制所依赖的四个基本实用程序。
showDialog — 原语
根构建块。 它接受一个渲染工厂,创建一个 Promise,将解析器传递为
done,并将工厂返回的任何内容渲染到 Ink 根中。
// interactiveHelpers.tsx
export function showDialog<T = void>(
root: Root,
renderer: (done: (result: T) => void) => React.ReactNode
): Promise<T> {
return new Promise<T>(resolve => {
const done = (result: T): void => void resolve(result);
root.render(renderer(done));
});
}
当用户选择一个选项时,组件调用 done(result),解决了 Promise。 没有 unmount 调用此处 — Ink 下次替换当前渲染树 root.render() 由流程的下一阶段调用。
showSetupDialog — 标准包装器
Wraps showDialog 每个对话都需要两个提供者:
AppStateProvider (全局 UI 状态)和 KeybindingSetup (键盘快捷键注册表)。 90% 的启动器都是这么称呼的。
export function showSetupDialog<T = void>(
root: Root,
renderer: (done: (result: T) => void) => React.ReactNode
): Promise<T> {
return showDialog<T>(root, done =>
<AppStateProvider>
<KeybindingSetup>{renderer(done)}</KeybindingSetup>
</AppStateProvider>
);
}
错误退出 / 消息退出
这些处理致命错误 after Ink 已经接管了终端。 自从墨水补丁
console.error, 清楚的 console.error 会被吞掉。 助手渲染 Ink <Text> 节点,卸载根,运行可选的清理钩子,然后调用 process.exit。 返回类型是 Promise<never>
— TypeScript 知道该函数不会返回。
The components/design-system/ 目录包含随处使用的固执己见的 Ink 包装器。 其中三个对于对话用户体验至关重要。
Dialog
用于任何确认/取消交互的标准镶边。 在构造时,它注册了两个键绑定: confirm:no (映射到 Esc 和 n) 调用 onCancel, 和 app:exit / app:interrupt (Ctrl-C/D) 退出该进程。 这
isCancelActive prop 在嵌入文本输入获得焦点时禁用这些绑定,以便 Ctrl-C 到达输入自己的处理程序。
// Dialog.tsx — keybinding wiring (simplified source)
const exitState = useExitOnCtrlCDWithKeybindings(undefined, undefined, isCancelActive);
useKeybinding("confirm:no", onCancel, { context: "Confirmation", isActive: isCancelActive });
// The input guide renders either "Press Ctrl-C again to exit"
// OR "Enter confirm / Esc cancel" depending on exitState.pending
const defaultInputGuide = exitState.pending
? <Text>Press {exitState.keyName} again to exit</Text>
: <Byline>
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
<ConfigurableShortcutHint action="confirm:no" fallback="Esc" description="cancel" />
</Byline>;
The color prop 接受来自的任何键 Theme 类型并转发至 Ink <Text> 呈现粗体标题,提供一致的语义着色("warning", "permission", "suggestion", ETC。)。
Dialog 注册自己的键绑定。 Pane 没有。 评论中
Pane.tsx 解释了拆分:“对于确认/取消对话框(Esc 关闭,Enter 确认),请使用 <Dialog>。 在窗格内呈现的子菜单应使用
hideBorder 在他们的对话框上,因此窗格的边框仍然是单个框架。”
Pane
斜杠命令屏幕使用的无边界区域(/config, /help,
/sandbox, ETC。)。 它渲染一条彩色分隔线作为其顶部边框和水平填充。 渲染时 在模态中 (通过检测到 useIsInsideModal())它完全跳过分隔线,因此不会破坏封闭模态的视觉框架。
PermissionDialog
仅由工具权限请求使用的专用框架。 不像 Dialog,它不注册任何键绑定 - 这些是由 PermissionPrompt 嵌套在其中。 它的视觉特征是圆形顶部边框(左/右/底部边框被抑制)
"permission" 主题颜色,带有 PermissionRequestTitle 标题可以显示 WorkerBadge (指示哪个子代理触发了请求)。
// PermissionDialog.tsx — terminal chrome (simplified source)
<Box
flexDirection="column"
borderStyle="round"
borderColor={color}
borderLeft={false}
borderRight={false}
borderBottom={false}
marginTop={1}
>
<Box paddingX={1} flexDirection="column">
<Box justifyContent="space-between">
<PermissionRequestTitle title={title} subtitle={subtitle} workerBadge={workerBadge} />
{titleRight}
</Box>
</Box>
<Box flexDirection="column" paddingX={innerPaddingX}>
{children}
</Box>
</Box>
每次 Claude 想要运行需要用户批准的工具时,都会显示权限请求 UI。 从工具类型到 UI 组件的路由是一个单一的 switch 中的声明
PermissionRequest.tsx:
// PermissionRequest.tsx — tool-to-component routing
function permissionComponentForTool(tool: Tool) {
switch (tool) {
case FileEditTool: return FileEditPermissionRequest;
case FileWriteTool: return FileWritePermissionRequest;
case BashTool: return BashPermissionRequest;
case PowerShellTool: return PowerShellPermissionRequest;
case WebFetchTool: return WebFetchPermissionRequest;
case SkillTool: return SkillPermissionRequest;
// …more tools…
default: return FallbackPermissionRequest;
}
}
功能标记工具(ReviewArtifactTool, WorkflowTool,
MonitorTool) 使用条件 require() 调用,因此仅当相关功能门打开时才加载它们的模块。
PermissionPrompt — 共享 UX 引擎
大多数权限请求组件将其交互部分委托给 PermissionPrompt。 它处理:
- 渲染一个
Select带有键入的选项值 - 可选的内联反馈文本输入(选项卡展开,用于接受/拒绝理由)
- 焦点跟踪,以便在反馈字段处于活动状态时键绑定能够正确运行
- 每次反馈交互的分析事件
- 默认问题文本: “你想继续吗?”
// PermissionPrompt.tsx — option shape
export type PermissionPromptOption<T extends string> = {
value: T;
label: ReactNode;
feedbackConfig?: {
type: 'accept' | 'reject';
placeholder?: string;
};
keybinding?: KeybindingAction; // optional hotkey shortcut
};
BashPermissionRequest——最复杂的情况
Bash 命令拥有最丰富的用户体验。 BashPermissionRequest adds:
分类器集成
调用 bash 分类器来检查命令是否安全。 检查时,显示动画闪烁字幕 — 隔离在 ClassifierCheckingSubtitle 以避免以 20 fps 重新渲染整个对话框。
允许规则
从命令计算“永远允许此前缀”规则,通过以下方式将它们作为选项提供 getSimpleCommandPrefix and getCompoundCommandPrefixesStatic.
警告显示
Calls getDestructiveCommandWarning 如果该命令被检测为破坏性的(rm -rf 等),则会呈现突出显示的警告。
沙盒回退
If shouldUseSandbox 返回 true,组件可以将执行路由到 SandboxManager 而不是需要明确的用户批准。
ClassifierCheckingSubtitle 源代码中明确注释了提取:“在提取之前,useShimmerAnimation 位于 535 行 Inner body 内部,因此每 50ms 时钟周期就会重新渲染整个对话框……Inner 还具有编译器救援功能,因此不会自动记忆任何内容 — 每次分类器检查都会重建完整的 JSX 树 20-60 次。” 这是一种罕见的、有记录的纯粹为了渲染性能而手动拆分组件的案例。
几乎每个对话框都使用 components/CustomSelect/select.tsx 而不是原始文本输入。 这 OptionWithDescription 联合类型使其强大:
// select.tsx — option type
type BaseOption<T> = {
label: ReactNode;
value: T;
description?: string;
disabled?: boolean;
};
// Two variants of OptionWithDescription:
type TextOption<T> = BaseOption<T> & { type?: 'text' };
type InputOption<T> = BaseOption<T> & {
type: 'input';
onChange: (value: string) => void;
placeholder?: string;
allowEmptySubmitToCancel?: boolean;
showLabelWithValue?: boolean;
resetCursorOnUpdate?: boolean;
};
An 'input'-type 选项在选择列表中嵌入实时文本字段。 这就是“是的,并永远允许这个命令: [在此输入前缀]" 在 bash 批准对话框中实现 - 该选项本身包含一个可编辑字段。
The components/wizard/ 目录实现了安装流程(助理设置、入门等)使用的轻量级多步骤向导。
状态容器
Holds currentStepIndex, wizardData、导航历史记录和完成状态。 通过暴露这些 WizardContext.
消费者挂钩
向导树内的任何组件都会调用 useWizard() 要得到 goNext, goBack, setData,以及当前步骤元数据。
每一步镀铬
将每个步骤包装在 <Dialog> 带有自动计算的标题,例如 “设置(2/4)”。 通行证 goBack as onCancel.
页脚提示
在每个步骤下方呈现上下文说明(例如“Tab 跳过/Enter 继续”)。
// WizardDialogLayout.tsx — how title + step counter are composed
const { currentStepIndex, totalSteps, title: providerTitle, goBack } = useWizard();
const title = titleOverride || providerTitle || "Wizard";
const stepSuffix = showStepCounter !== false
? ` (${currentStepIndex + 1}/${totalSteps})`
: "";
return <>
<Dialog
title={`${title}${stepSuffix}`}
subtitle={subtitle}
onCancel={goBack} // back = cancel for each step
isCancelActive={false} // wizard manages its own exit
hideInputGuide={true}
>{children}</Dialog>
<WizardNavigationFooter instructions={footerText} />
</>
Note isCancelActive={false} — 向导禁用 Dialog 的内置 Esc 处理程序,因为 WizardProvider 通过注册自己的退出处理程序
useExitOnCtrlCDWithKeybindings(),确保 Ctrl-C 退出整个向导而不仅仅是当前步骤。
Onboarding.tsx 是最大的类似对话框的组件,也是上述所有模式协同工作的一个很好的例子。 它使用类型化管理自己的步骤数组
StepId 值,记录每次转换的分析,并有条件地包括基于运行时环境的步骤。
type StepId = 'preflight' | 'theme' | 'oauth' | 'api-key' | 'security' | 'terminal-setup';
interface OnboardingStep {
id: StepId;
component: React.ReactNode;
}
步骤是带有 id 和一个预渲染的 component。 当前步骤的组件是有条件渲染的。 在每个 goToNextStep()
调用步骤的 id 被发送到分析作为
'tengu_onboarding_step'。 当所有步骤都用尽时, onDone()
被调用,它解析外部对话框 Promise 并将控制返回给 main.tsx.
TrustDialog 是在 REPL 会话启动之前运行的权限请求变体。 它使用 PermissionDialog (not Dialog)和一个 Select
让用户选择是否信任当前项目目录。 它读取钩子源、MCP 服务器配置、bash 权限源和危险的环境变量,以生成用户批准的上下文感知的安全问题列表。
要点
dialogLaunchers.tsx将每个对话框转换为返回类型化 Promise 的异步函数 - 调用者永远不会直接接触 Ink 内部。showSetupDialog是唯一一个地方AppStateProviderandKeybindingSetup已添加; 所有对话框均可免费获得。Dialog,Pane, 和PermissionDialog是三个不同的原语,具有明确记录的用例 - 它们不可互换。- The
isCancelActive支撑Dialog解决了一个真正的冲突:嵌入的文本输入需要 Ctrl-C 来取消,而不是退出进程。 - 权限请求通过单个将工具类型路由到 UI 组件
switchinPermissionRequest.tsx; 功能标记工具使用条件require(). PermissionPrompt是所有工具审批的共享用户体验引擎——它处理反馈输入、分析和按键绑定,以便各个请求组件保持精简。- The
'input'-typeCustomSelect选项在选择列表中嵌入一个实时文本字段——这是 bash 批准中“允许前缀”选项背后的机制。 - 向导模式使用
WizardProvider+useWizard+WizardDialogLayout; 它禁用 Dialog 的 Esc 处理程序并管理其自己的退出生命周期。 ClassifierCheckingSubtitle纯粹是为了防止 20fps 的闪烁时钟重新渲染整个 bash 权限对话框树而提取的——这是一个记录在案的性能胜利。
知识检查
showDialog in interactiveHelpers.tsx 返回?WizardDialogLayout set isCancelActive={false} 在内部 Dialog?BashTool) 向其定义的权限请求 UI 组件?ClassifierCheckingSubtitle 到它自己的组件中 BashPermissionRequest?'input'- 键入选项 CustomSelect 从常规的 'text' 选项?