阶段 4 | 组件库与设计系统¶
目标:理解 Claude Code 的 TUI 组件分层 —— 从原子组件到业务组件到消息渲染器。 时长:1~2 天 前端类比:shadcn/ui + Radix + TipTap(编辑器)的 TUI 对应物。
4.1 三层组件架构¶
components/
├── design-system/ 14 文件 原子层:Dialog/Tabs/ThemeProvider/ListItem... ← shadcn/ui
├── ui/ 3 文件 中等通用:OrderedList/TreeSelect/OrderedListItem
├── messages/ 21 文件 业务层:按 message type 分的渲染器 ← 业务组件
├── permissions/ ~5 文件 业务层:权限询问/确认 ← 业务组件
├── diff/ 3 文件 业务层:diff 详情视图
├── StructuredDiff.tsx + StructuredDiffList.tsx 渲染:含语法高亮的 diff
├── HighlightedCode/ 1 文件 渲染:语法高亮
├── Markdown.tsx + MarkdownTable.tsx 渲染:Markdown 文本
├── messages/ 21 文件 渲染:message 类型分发
├── PromptInput/ ~20 文件 业务层:输入框(含子组件和 hooks)
├── <100 个其他 .tsx> 业务层:各种 dialog/wizard/select
└── App.tsx 根组件
对照 Web:
- design-system/ ≈ shadcn/ui
- ui/ ≈ Radix Primitives
- messages/ ≈ 业务组件库(按 type 分类的 React.lazy + Suspense)
- Markdown.tsx + HighlightedCode/ ≈ react-markdown + shiki
4.2 设计系统层:src/components/design-system/¶
总行数:2208 行,14 个组件 设计目标:跨"屏幕"复用、有完整键盘交互、有主题感知
4.2.1 组件清单¶
| 组件 | 行数 | 作用 | Web 类比 |
|---|---|---|---|
Byline.tsx |
76 | 元信息行(标题+副标题+快捷键提示) | Card.Header |
Dialog.tsx |
137 | 模态对话框基底 | Radix Dialog |
Divider.tsx |
148 | 分割线(含文字) | <hr> |
FuzzyPicker.tsx |
311 | 模糊搜索选择器 | fzf / cmdk |
KeyboardShortcutHint.tsx |
80 | 快捷键提示 | kbd |
ListItem.tsx |
243 | 列表项(含选中态) | <li> with active state |
LoadingState.tsx |
93 | 加载态(spinner+文案) | Spinner |
Pane.tsx |
76 | 面板容器 | Card |
ProgressBar.tsx |
85 | 进度条 | <progress> |
Ratchet.tsx |
79 | 计数 ratchet 动画 | 计数器动画 |
StatusIcon.tsx |
94 | 状态图标(成功/失败/警告/信息) | Alert icon |
Tabs.tsx |
339 | Tab 切换 | Radix Tabs |
ThemedBox.tsx |
155 | 主题化的 <Box> |
styled.div |
ThemedText.tsx |
123 | 主题化的 <Text> |
styled.span |
ThemeProvider.tsx |
169 | 主题 Provider(含持久化和 preview) | next-themes |
4.2.2 关键设计:Dialog.tsx¶
// src/components/design-system/Dialog.tsx:14
type DialogProps = {
title: React.ReactNode;
subtitle?: React.ReactNode;
children: React.ReactNode;
onCancel: () => void;
color?: keyof Theme;
hideInputGuide?: boolean;
hideBorder?: boolean;
inputGuide?: (exitState: ExitState) => React.ReactNode;
/**
* Controls whether Dialog's built-in confirm:no (Esc/n) and app:exit/interrupt
* (Ctrl-C/D) keybindings are active. Set to `false` while an embedded text
* field is being edited so those keys reach the field instead of being
* consumed by Dialog. TextInput has its own ctrl+c/d handlers...
* Defaults to `true`.
*/
isCancelActive?: boolean;
};
关键洞察 1:isCancelActive —— Dialog 和嵌入的 TextInput 共享 Esc/Ctrl-C 键。Dialog 收到 Esc 关闭自己,TextInput 收到 Esc 也有自己的语义(删除字符)。怎么解决键位冲突?显式 prop 协调。
关键洞察 2:注释里写 "TextInput has its own ctrl+c/d handlers (cancel on press, delete-forward on ctrl+d with text)"。这告诉我们:键位分发是分层的,外层 Dialog 决定"透传还是拦截"。
前端类比:Web 项目用 e.stopPropagation(),TUI 用 prop 显式声明。
4.2.3 关键设计:ThemeProvider.tsx¶
type ThemeContextValue = {
/** The saved user preference. May be 'auto'. */
themeSetting: ThemeSetting;
setThemeSetting: (setting: ThemeSetting) => void;
setPreviewTheme: (setting: ThemeSetting) => void;
savePreview: () => void;
cancelPreview: () => void;
/** The resolved theme to render with. Never 'auto'. */
currentTheme: ThemeName;
};
const DEFAULT_THEME: ThemeName = 'dark';
const ThemeContext = createContext<ThemeContextValue>({ ... });
关键洞察:ThemeProvider 设计了"preview"机制 —— 用户在 theme picker 选主题时只 preview,确认后才 save。和图片编辑器的"裁剪不保存"是同种 UX 模式。
前端类比:next-themes 库也是这种实现。
4.2.4 关键设计:Tabs.tsx¶
type TabsProps = {
children: Array<React.ReactElement<TabProps>>;
title?: string;
color?: keyof Theme;
defaultTab?: string;
hidden?: boolean;
useFullWidth?: boolean;
/** Controlled mode: current selected tab id/title */
selectedTab?: string;
/** Controlled mode: callback when tab changes */
onTabChange?: (tabId: string) => void;
banner?: React.ReactNode;
disableNavigation?: boolean;
isDisabled?: boolean; // 初始 header 焦点
};
关键洞察 1:同时支持受控和非受控模式(selectedTab + onTabChange vs defaultTab),和 React 受控组件 API 一致。
关键洞察 2:isDisabled(注释里叫 "headerFocused")—— 焦点管理。Tab 有"header 区域"和"内容区域",初始焦点在 header 让 arrow 键切 tab,传 false 让焦点在内容(让 Select 之类组件响应 up/down)。
前端类比:和 Material UI 的 autoFocus + tabIndex 管理是同种思路。
4.3 业务组件层:src/components/ 主体¶
平铺 100+ 业务组件,按职责自动聚类成目录(PromptInput/、diff/、permissions/、mcp/、design-system/)。
4.3.1 业务组件分类速查¶
| 子目录 | 组件 | 职责 |
|---|---|---|
PromptInput/ |
输入框(20 个子文件) | 用户输入 |
permissions/ |
PermissionRequest 等 |
工具调用权限确认 |
diff/ |
DiffDetailView / DiffFileList / DiffDialog |
Diff 全屏视图 |
mcp/ |
MCPServerDialog / ElicitationDialog / mcpConfig.* |
MCP 服务器管理 |
mcp/ |
各种 *MCP*.tsx |
MCP 工具选择、配置 |
CustomSelect/ |
自定义下拉选择 | 替代 Ink 默认 Select |
HighlightedCode/ |
语法高亮 | 复用 shiki 风格 |
messages/ |
21 个 message 渲染器 | 见 4.4 |
Spinner/ |
Spinner 组件 | 加载态 |
StructuredDiff* |
diff 列表 | 文件级 diff |
wizard/ |
多步表单 | onboarding 等 |
groove/ / grove/ |
"区域"布局 | 复合组件 |
Passes/ |
计费 tier 选择 | Claude.ai 订阅 |
tasks/ |
任务管理 UI | 任务列表 |
teams/ |
团队模式 UI | 多 agent |
mcp/, LspRecommendation/, DesignSystem* |
... | ... |
💡 阅读技巧:
ls src/components/一遍,按名字分桶归类,然后只挑一类深读。
4.3.2 通用模式:Dialog + 内容¶
// 典型业务 dialog 模式
function McpServerDialog({ onClose, onApprove }) {
return (
<Dialog title="MCP Server Configuration" onCancel={onClose}>
<Box flexDirection="column">
<List>...</List>
<KeyboardShortcutHint keys="Enter" label="Confirm" />
<KeyboardShortcutHint keys="Esc" label="Cancel" />
</Box>
</Dialog>
);
}
所有 dialog 都遵循这个结构:Dialog (基底) → Box (布局) → List/ListItem (内容) → KeyboardShortcutHint (操作提示)。
💡 这是 TUI 版本的"设计模式" —— Dialog-as-Modal,List-as-Content,Hint-as-Action。
4.4 消息渲染管线:src/components/messages/¶
21 个组件,每个对应一种
Message类型 角色:dispatch table,把message.type映射到具体渲染器
4.4.1 完整列表¶
| 文件 | 对应 message type | 内容 |
|---|---|---|
AssistantTextMessage.tsx |
assistant_text |
LLM 文本回复 |
AssistantThinkingMessage.tsx |
assistant_thinking |
LLM 思考过程 |
AssistantRedactedThinkingMessage.tsx |
assistant_redacted_thinking |
加密的 thinking |
AssistantToolUseMessage.tsx |
assistant_tool_use |
工具调用 |
AttachmentMessage.tsx |
attachment |
附件 |
CollapsedReadSearchContent.tsx |
折叠的 read/grep 内容 | 长内容折叠 |
CompactBoundaryMessage.tsx |
上下文压缩分界 | 显示"已压缩 N 条" |
GroupedToolUseContent.tsx |
多个 tool_use 合并渲染 | 性能优化 |
HighlightedThinkingText.tsx |
thinking 高亮 | 复用 HighlightedCode |
HookProgressMessage.tsx |
hook 进度 | session hook 执行反馈 |
PlanApprovalMessage.tsx |
plan 审批 | Plan Mode |
RateLimitMessage.tsx |
限流 | 限流提示 |
ShutdownMessage.tsx |
关闭 | 会话结束 |
SystemAPIErrorMessage.tsx |
API 错误 | 错误展示 |
SystemTextMessage.tsx |
系统消息 | 通用系统消息 |
TaskAssignmentMessage.tsx |
任务分配 | 多 agent 模式 |
teamMemCollapsed.tsx |
队友记忆折叠 | swarm 模式 |
teamMemSaved.ts |
队友记忆已存 | swarm 模式 |
nullRenderingAttachments.ts |
隐藏附件 | 占位 |
messages.ts (推测) |
顶层 dispatch | 消息树根 |
4.4.2 Dispatch 模式¶
// 推测的 dispatch(在 components/Messages.tsx 或类似文件)
function MessageRenderer({ message }) {
switch (message.type) {
case 'user': return <AttachmentMessage msg={message} />;
case 'assistant_text': return <AssistantTextMessage msg={message} />;
case 'assistant_thinking': return <AssistantThinkingMessage msg={message} />;
case 'tool_use': return <AssistantToolUseMessage msg={message} />;
case 'plan_approval': return <PlanApprovalMessage msg={message} />;
// ... 20+ cases
default: return null;
}
}
前端类比:和 React Router v6 的 route config、Web 项目的 error boundary switch 一样。
4.4.3 性能模式:GroupedToolUseContent¶
观察 GroupedToolUseContent.tsx 的存在 —— 多个连续 tool_use 合并渲染,避免 N 个 <ToolUseMessage> 实例。
前端类比:和 react-window 的 "list virtualization" 不同,这是 "row grouping"。在 IM 客户端常见("Alice 发送了 3 张图片"合并成一行)。
4.5 复杂渲染器深读¶
4.5.1 Markdown.tsx —— 文本渲染核心¶
完整路径:
src/components/Markdown.tsx
// src/components/Markdown.tsx 头部
import { marked, type Token, type Tokens } from 'marked';
import { type CliHighlight, getCliHighlightPromise } from '../utils/cliHighlight.js';
import { hashContent } from '../utils/hash.js';
import { configureMarked, formatToken } from '../utils/markdown.js';
// Module-level token cache — marked.lexer is the hot cost on virtual-scroll
// remounts (~3ms per message). useMemo doesn't survive unmount→remount, so
// scrolling back to a previously-visible message re-parses. Messages are
// immutable in history; same content → same tokens. Keyed by hash to avoid
// retaining full content strings (turn50→turn99 RSS regression, #24180).
const TOKEN_CACHE_MAX = 500;
const tokenCache = new Map<string, Token[]>();
// Characters that indicate markdown syntax. If none are present, skip the
// ~3ms marked.lexer call entirely — render as a single paragraph.
3 个关键优化:
1. Module-level LRU tokenCache(500 项)—— useMemo 在虚拟滚动时不可靠(unmount → remount 会丢缓存),改用 module-level Map。注释还提到"turn50→turn99 RSS regression"——历史上踩过内存泄漏坑,所以用 hash 作为 key 而不是原始字符串。
2. Skip-on-no-markdown 短路 —— 先 regex 扫一遍字符串,如果没有 MD 标记就直接当纯文本渲染,跳过 ~3ms 的 marked.lexer 调用。
3. Suspense + use() 异步高亮 —— 用 React 18 的 use() 读 Promise,配合 Suspense 异步加载语法高亮结果,不阻塞渲染。
💡 这是大型 TUI 项目的典型性能教训:和 Web 项目的 "useMemo vs cache" 一样,TUI 的虚拟滚动也有同样的"unmount/remount 缓存失效"问题。解决方案是 module-level cache(不依赖 React 生命周期)。
4.5.2 StructuredDiff.tsx + StructuredDiffList.tsx —— Diff 渲染¶
核心路径:
src/components/StructuredDiff.tsx
// src/components/StructuredDiff.tsx 头部
// REPL.tsx renders <Messages> at two disjoint tree positions (transcript
// early-return vs prompt-mode nested in FullscreenLayout), so ctrl+o
// unmounts/remounts the entire message tree and React's memo cache is lost.
// Keep both the NAPI result AND the pre-split gutter/content columns at
// module level so the only work on remount is a WeakMap lookup plus two
// <ink-raw-ansi> leaves — not a fresh syntax highlight, nor N sliceAnsi
// calls + 6N Yoga nodes.
//
// PR #21439 (fullscreen default-on) made gutterWidth>0 the default path,
// reactivating the per-line <DiffLine> branch that PR #20378 had bypassed.
这段注释是教科书级的"性能考古":
- 问题 1:REPL 在两处渲染 <Messages>(transcript 早返回 vs FullscreenLayout 内),Ctrl+O 全屏切换会 unmount/remount 整棵树,React memo 缓存失效
- 问题 2:每次重挂载会重新做语法高亮、调用 N 次 sliceAnsi、分配 6N 个 Yoga 节点
- 解决方案:NAPI 高亮结果 + 预切分的 gutter/content 列都提升到 module-level,重挂载时只做 WeakMap 查询 + 2 个 ink-raw-ansi 叶子
💡 学习价值:这段注释是"实战经验"——
#21439/#20378是 PR 编号,记录了"为什么这里这样写"。读 Claude Code 注释 = 读一本 4 年实战经验总结。
关键文件:
- src/components/StructuredDiff.tsx —— 单个 hunk 渲染
- src/components/StructuredDiffList.tsx —— 多个 hunk 列表(含省略号分隔)
- src/components/StructuredDiff/Fallback.tsx —— 降级方案
- src/components/StructuredDiff/colorDiff.ts —— 颜色对比度计算
- src/components/diff/DiffDetailView.tsx / DiffDialog.tsx / DiffFileList.tsx —— 全屏 diff 视图
4.5.3 HighlightedCode/ —— 语法高亮¶
路径:
src/components/HighlightedCode/Fallback.tsx(主文件)
推测用 cliHighlight 工具做语法高亮(src/utils/cliHighlight.ts 暴露 getCliHighlightPromise)。可能是基于 Tree-sitter 或 NAPI 绑定(参考 native-ts 目录)。
4.6 主题系统¶
路径:
src/components/design-system/ThemeProvider.tsx(169 行)
4.6.1 主题架构¶
utils/theme.ts → ThemeName 类型 + 颜色定义
utils/systemTheme.ts → 监听系统主题(macOS / Windows API)
ThemeProvider.tsx → React Context,解析 'auto' / 'light' / 'dark'
ThemedBox.tsx → 读 theme 的 Box
ThemedText.tsx → 读 theme 的 Text
4.6.2 持久化与 preview¶
type ThemeContextValue = {
themeSetting: ThemeSetting; // 'auto' | 'light' | 'dark' | ...
setThemeSetting: (s) => void; // 永久保存
setPreviewTheme: (s) => void; // 临时预览
savePreview: () => void; // 把 preview 确认
cancelPreview: () => void; // 撤销 preview
currentTheme: ThemeName; // 实际渲染用的(解析 auto)
};
💡 "preview" 模式:Web 项目通常用
useState+useEffect+localStorage,Claude Code 把"preview vs save"提升到 API 层。和图片编辑器的"裁剪但未保存"是同种 UX。
4.7 关键洞察¶
4.7.1 "组件即设计约束"¶
每个 Dialog 都遵循 <Dialog><Box>...</Box></Dialog> 结构 —— 设计模式即约束。新加 dialog 不会跑偏。
4.7.2 "props 即文档"¶
看 DialogProps 注释密度就知道项目质量。注释解释 为什么(不是 什么),比如 isCancelActive 的注释解释了"TextInput 有自己的 ctrl+c/d handler"。
4.7.3 "性能注释即历史"¶
StructuredDiff.tsx 顶部的注释引用了 PR 编号 #21439 / #20378 —— 性能优化是有历史的,每次优化都基于前一次踩的坑。读这些注释 = 读性能优化演进史。
4.7.4 "DCE / Ant-only 在业务层也大量存在"¶
很多 *Callout 组件、*Bridge 组件都有 require + 条件判断,外部构建是 null。
读源码时跳过这些分支能省一半时间。
4.8 阅读清单¶
- ✅
src/components/design-system/Dialog.tsx(137 行全文)—— 设计模式标本 - ✅
src/components/design-system/ThemeProvider.tsx(169 行全文)—— 主题/持久化/preview - 🔍
src/components/Markdown.tsx:1-80(优化注释)—— 性能优化标本 - 🔍
src/components/StructuredDiff.tsx:1-50(PR 历史注释)—— 实战经验标本 - 📌
src/components/messages/(挑 3 个读:AssistantTextMessage、ToolUseMessage、PlanApprovalMessage) - 📌
src/components/diff/DiffFileList.tsx—— diff 全屏视图 - 📌
src/components/permissions/PermissionRequest.tsx—— 权限询问 UI
4.9 练习任务¶
- 画
Dialog的状态机:列出isCancelActive=true/false、TextInput 焦点/失焦、Esc/Ctrl-C 按下时的所有交互路径 - 手写一个 LRU cache(500 项)—— 用
Map的keys().next()实现 O(1) LRU - 找一个有 4 个 DCE/Ant-only 分支的业务组件,分析哪些外部看不到
- 对比 Web:如果你用 shadcn/ui + Radix 实现"主题 picker with preview",代码会比 Claude Code 的 ThemeProvider 简单还是复杂?差异在哪?
4.10 下一步¶
进入 阶段 5:工具调用系统 —— LLM 怎么"调用工具"?前端怎么渲染?用户怎么授权?