跳转至

阶段 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;
};

关键洞察 1isCancelActive —— 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 一致。

关键洞察 2isDisabled(注释里叫 "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 阅读清单

  1. src/components/design-system/Dialog.tsx(137 行全文)—— 设计模式标本
  2. src/components/design-system/ThemeProvider.tsx(169 行全文)—— 主题/持久化/preview
  3. 🔍 src/components/Markdown.tsx:1-80(优化注释)—— 性能优化标本
  4. 🔍 src/components/StructuredDiff.tsx:1-50(PR 历史注释)—— 实战经验标本
  5. 📌 src/components/messages/(挑 3 个读:AssistantTextMessage、ToolUseMessage、PlanApprovalMessage)
  6. 📌 src/components/diff/DiffFileList.tsx —— diff 全屏视图
  7. 📌 src/components/permissions/PermissionRequest.tsx —— 权限询问 UI

4.9 练习任务

  1. Dialog 的状态机:列出 isCancelActive=true/false、TextInput 焦点/失焦、Esc/Ctrl-C 按下时的所有交互路径
  2. 手写一个 LRU cache(500 项)—— 用 Mapkeys().next() 实现 O(1) LRU
  3. 找一个有 4 个 DCE/Ant-only 分支的业务组件,分析哪些外部看不到
  4. 对比 Web:如果你用 shadcn/ui + Radix 实现"主题 picker with preview",代码会比 Claude Code 的 ThemeProvider 简单还是复杂?差异在哪?

4.10 下一步

进入 阶段 5:工具调用系统 —— LLM 怎么"调用工具"?前端怎么渲染?用户怎么授权?