22-设计系统
第 22 篇:设计系统 — 终端 UI 的组件化实践
为什么终端应用需要设计系统?
当你用 console.log 输出几行文字时,不需要设计系统。但当你的终端应用有流式输出、多工具并行进度条、权限确认对话框、Tab 切换面板、模糊搜索选择器、dark/light 主题切换时,情况就完全不同了。
Claude Code 面对的正是这样的复杂度。它的 UI 不是静态的信息打印,而是一个高度动态的交互界面。在第 21 篇中我们看到了 forked Ink 框架如何在终端中运行 React;本篇则关注在这个框架之上,团队如何构建了一套可复用的组件化设计系统。
这套设计系统回答三个核心问题:
- 颜色从哪来? — 6 套主题 × 80+ 语义化颜色 token 的主题系统
- 组件怎么组合? — 15 个设计系统组件 + 1 个颜色工具函数的职责划分
- 工具 UI 怎么统一? — Tool 接口中 10 个 render/查询方法构成的 UI 协议
一、主题系统:80+ 语义化颜色 token
1.1 Theme 类型:颜色的语义化契约
终端应用的颜色管理比 Web 更复杂。Web 有 CSS 变量和成熟的设计 token 体系,终端只有 ANSI 转义序列。Claude Code 的解法是定义一个 Theme 类型作为颜色的语义化契约:
1 | // utils/theme.ts:4-89 |
几个值得注意的设计决策:
命名即约束:red_FOR_SUBAGENTS_ONLY 这种”尖叫式”命名不是偶然的。它在类型层面告诉消费者:这个颜色只该用于子 Agent 的视觉区分,不要在其他地方用红色。这比注释更有效 —— 你在代码审查中会立刻注意到 theme.red_FOR_SUBAGENTS_ONLY 出现在非 Agent 的上下文中。
Shimmer 配对:几乎每个主色都有一个对应的 *Shimmer 变体(更浅的版本)。这是为微光动画效果准备的 —— 在两个颜色之间交替切换产生闪烁效果。
80+ 字段:整个 Theme 类型有 80 多个颜色字段,覆盖了从 Diff 高亮到速率限制进度条的所有场景。
1.2 六套主题:从 True Color 到 16 色 ANSI
1 | // utils/theme.ts:91-98 |
六套主题按两个维度组合:
| 维度 | 选项 | 说明 |
|---|---|---|
| 明暗 | dark / light | 跟随终端背景色 |
| 色彩能力 | 标准 / daltonized / ansi | 24-bit RGB / 色盲友好 / 仅 16 色 |
标准主题使用显式 RGB 值(如 'rgb(215,119,87)')。源码注释明确说明原因:
1 | // utils/theme.ts:112-115 |
用户可能把终端的”红色”设成了粉色,所以不能依赖 ANSI 命名色。
ANSI 主题使用 'ansi:red'、'ansi:blueBright' 等命名色,适配不支持 True Color 的终端。
Daltonized(色盲友好)主题最值得一提 —— 它不是简单地调整饱和度,而是重新选择语义映射:
1 | // utils/theme.ts:380-381 |
对于红绿色盲用户,”成功=绿色”是无法区分的。色盲主题将 success 映射到蓝色,error 保持纯红 —— 蓝和红对 deuteranopia 用户来说对比足够大。
1.3 Auto 主题:运行时检测终端明暗
1 | // utils/theme.ts:103-109 |
用户配置中可以选 'auto',但实际渲染时必须解析为具体的 ThemeName。这个解析发生在 ThemeProvider 中:
1 | // components/design-system/ThemeProvider.tsx:81 |
auto 模式检测的是终端的实际背景色,而非操作系统的 appearance 设置 —— 一个 dark 终端运行在 light-mode 的 macOS 上,仍然应该解析为 'dark'。具体机制分两步(utils/systemTheme.ts):
- 同步猜测:启动时读取
$COLORFGBG环境变量(rxvt 约定的fg;bg格式,部分终端如 iTerm2、Konsole 会设置),根据 ANSI 颜色索引判断明暗(0-6 和 8 是 dark,7 和 9-15 是 light)。 - 异步校正:
ThemeProvider中的watchSystemTheme通过 OSC 11 终端查询协议获取真实背景色 RGB 值,用 ITU-R BT.709 相对亮度公式判断明暗(luminance > 0.5为 light),校正模块级缓存。
当用户在运行期间切换了终端的明暗配色,OSC 11 watcher 会捕捉到变化并更新 UI。
1.4 Apple Terminal 兼容:256 色降级
1 | // utils/theme.ts:616-619 |
Apple Terminal 不能正确处理 24-bit 颜色转义序列,所以针对它创建了一个降级的 chalk 实例,只使用 256 色模式。这种按终端逐个适配的做法在终端应用开发中很常见。
二、ThemeProvider:React Context 桥接
主题系统的 React 层由 ThemeProvider + useTheme 组成,遵循经典的 Context 模式,但有几个终端特有的设计:
1 | // components/design-system/ThemeProvider.tsx:43-116 |
预览机制:previewTheme 状态允许主题选择器(ThemePicker)在用户上下翻选时实时预览主题效果,而不修改持久化配置。按 Enter 确认(savePreview)才写入配置文件,按 Esc 取消(cancelPreview)恢复原主题。
三种 Hook暴露了不同粒度的接口(以下为实际返回值):
| Hook | 返回值 | 使用场景 |
|---|---|---|
useTheme() |
[ThemeName, (setting: ThemeSetting) => void] |
普通组件获取已解析的渲染主题;setter 接受 ThemeSetting(包括 'auto') |
useThemeSetting() |
ThemeSetting |
ThemePicker 等需要展示 'auto' 作为独立选项的 UI |
usePreviewTheme() |
{ setPreviewTheme, savePreview, cancelPreview } |
ThemePicker 控制实时预览 → 确认 → 取消的完整流程 |
注意 useTheme() 返回的第一个值永远是已解析的 ThemeName(不会是 'auto'),但 setter 可以传入 'auto' —— 它会触发 systemTheme watcher 重新检测终端明暗。
三、基础组件:ThemedText 与 ThemedBox
3.1 颜色解析的统一入口
ThemedText 和 ThemedBox 是整个设计系统的两个基石组件。它们的核心职责只有一个:将 Theme key 解析为实际颜色值。
1 | // components/design-system/ThemedText.tsx:66-74 |
resolveColor 函数支持双通道输入:既接受 Theme key(如 'success'),也接受原始颜色值(如 'rgb(0,255,0)')。这让组件既能享受主题化,又能在必要时绕过主题。
ThemedText 在此基础上增加了两个能力:
1 | // components/design-system/ThemedText.tsx:80-108 |
TextHoverColorContext:这是一个跨 Box 边界的颜色级联机制。在 Web 上,CSS 的 color 属性天然继承。但 Ink 的样式不会跨 Box 组件继承。TextHoverColorContext 解决了这个问题 —— 父组件设置一个 hover 颜色,所有子树中未着色的 ThemedText 都会使用它。
dimColor 不是 ANSI dim:源码注释明确指出,dimColor 使用的是 theme.inactive 颜色,而不是 ANSI 的 dim 属性。这是因为 ANSI dim 和 bold 不能同时生效(在某些终端上),但 inactive 颜色 + bold 可以。
3.2 非 React 场景的颜色工具
不是所有颜色使用都在 React 组件中。日志输出、asciichart 图表等场景需要在 React 之外使用主题颜色:
1 | // components/design-system/color.ts:9-30 |
这是一个柯里化函数:先传入颜色和主题,返回一个 string → string 的着色函数。这样可以预创建着色器,在循环中复用。
四、布局组件:Pane、Divider、Dialog
4.1 Pane:斜杠命令的标准容器
1 | // components/design-system/Pane.tsx:33-76 |
Pane 是所有斜杠命令屏幕(/config、/help、/plugins 等)的标准容器。它的设计体现了上下文感知:在 Modal 内渲染时自动省略 Divider,因为 Modal 自己有边框。源码注释详细说明了选择理由,甚至引用了 Issue 编号(#23592)解释 flexShrink={0} 的必要性。
4.2 Divider:全宽分隔线
1 | // components/design-system/Divider.tsx:66-148 |
Divider 支持带标题的居中分隔(如 ─── 3 new messages ───),并处理了 ANSI 字符宽度计算(stringWidth 而非 title.length,因为 ANSI 转义序列不占显示宽度)。
4.3 Dialog:确认/取消对话框
1 | // components/design-system/Dialog.tsx:30-137 |
几个设计亮点:
isCancelActive开关:当 Dialog 内嵌了 TextInput 时,需要临时禁用 Dialog 的 Esc/n 快捷键,否则用户输入 ‘n’ 会被 Dialog 捕获为取消。这是终端 UI 中常见的快捷键冲突管理问题。hideBorder支持:当 Dialog 嵌套在 Pane 内时,可以隐藏自己的边框,避免双重边框。- 默认
color='permission':权限确认是最常见的 Dialog 场景,蓝紫色成为默认色。
五、交互组件:Tabs、ProgressBar、FuzzyPicker
5.1 Tabs:三层焦点协作模型
Tabs 组件是设计系统中最复杂的组件,它实现了一个三层焦点协作模型:
1 | ┌─ Header focused ─────────────────────┐ |
源码中实际存在三层控制(Tabs.tsx):
第一层:默认 Header 导航。initialHeaderFocused 默认 true,Tab/←/→ 在 Header 区切换标签。对于只使用 ↑/↓ 的 Select/list 内容,不存在按键冲突,所以这套默认行为开箱即用。
第二层:opt-in blur/focus 协作。内容组件调用 useTabHeaderFocus() Hook 注册 opt-in,这会启用 ↓ 键从 Header 退出到内容。registerOptIn 使用引用计数(optInCount),只有 optInCount > 0 时 ↓ 键才生效 —— 旧版 Tab 内容不知道双层焦点的存在,如果强制启用 ↓ 键退出 Header,用户按了 ↓ 之后就没有办法回到 Header(因为旧内容没有注册 ↑ 回退)。
第三层:navFromContent 额外放权。navFromContent prop 允许内容区在聚焦时也能用 Tab/←/→ 切换标签。这是 opt-in 之上的额外放权 —— 有些内容(如枚举值循环)自己占用了 ←/→ 键,此时不能开启这个选项;但对于纯文本展示的 Tab,开启后用户无需先 ↑ 回 Header 就能直接切标签。
5.2 ProgressBar:Unicode 分段进度条
1 | // components/design-system/ProgressBar.tsx:26 |
进度条使用 9 个 Unicode block 字符实现亚字符精度。一个字符的宽度被分成 8 级(1/8 字符精度):
1 | // ProgressBar 核心逻辑 |
这比简单的 [==== ] 风格进度条精确 8 倍。
5.3 其他关键组件
| 组件 | 职责 |
|---|---|
ListItem |
选择列表项:焦点指示器(❯)+ 选中标记(✓)+ 滚动指示器(↓↑)+ 禁用状态 |
StatusIcon |
状态图标:success ✓ / error ✗ / warning ⚠ / info ℹ / pending ○ / loading … |
FuzzyPicker |
模糊搜索选择器:SearchBox + 列表 + 预览面板 + 自适应高度 |
LoadingState |
异步加载状态:Spinner + 消息 + 可选副标题 |
Byline |
元数据分隔:Enter to confirm · Esc to cancel(自动过滤 null 子节点) |
KeyboardShortcutHint |
快捷键提示:Enter to confirm(可选括号包裹和 bold) |
Ratchet |
单调递增高度锁:内容缩小时保持最大高度,防止布局抖动 |
Ratchet(棘轮)组件特别值得一提 —— 它的名字来自机械棘轮只能单方向转动的特性。在终端中,如果一个组件的高度频繁变化(比如流式输出时行数在增减),会导致下方内容不断跳动。Ratchet 记录历史最大高度并锁定 minHeight,保证高度只增不减。
六、工具 UI 协议:Tool 接口的 10 个 Render/查询方法
设计系统的另一个关键维度是工具 UI 协议 —— 在 Tool.ts:566-694 中定义了每个工具必须或可选实现的渲染与查询方法。完整清单如下:
1 | // Tool.ts:566-694(完整协议,非简化) |
相比”概念上 6 个方法”的简化理解,实际协议覆盖了正常态、异常态、聚合态、搜索索引四个维度,共 10 个方法。几个重要的设计原则:
Partial Input:renderToolUseMessage 接收的是 Partial<Input> 而非 Input。这是因为流式传输时,工具参数可能还没有完全到达 —— 比如 BashTool 可能先收到 command 字段,timeout 字段还在路上。组件必须能优雅地处理不完整输入。
verbose 模式:多数 render 方法都接收 verbose 参数。非 verbose 模式显示精简信息(如只显示文件名),verbose 模式显示完整信息(如完整的 diff)。isResultTruncated 方法告诉 UI 框架是否需要展示”点击展开”的交互提示。
丰富的 options 上下文:renderToolResultMessage 的 options 不仅有 theme 和 verbose,还传入了 tools(当前工具集合)、isTranscriptMode(是否在 transcript 回放中)、isBriefOnly(brief 模式)、input(原始调用参数,用于结果摘要引用请求内容)。这让工具 UI 可以根据不同渲染场景自适应。
theme 透传:渲染方法直接接收 ThemeName 而非从 Context 中获取。这让工具 UI 可以在 React 树之外渲染(如 transcript 导出场景)。
Fallback 降级:renderToolUseRejectedMessage 和 renderToolUseErrorMessage 都是可选的,省略时使用 <FallbackToolUseRejectedMessage /> 和 <FallbackToolUseErrorMessage /> 通用组件。只有需要自定义异常态 UI 的工具(如文件编辑工具展示被拒绝的 diff)才需要实现。
搜索索引协同:extractSearchText 为 transcript 搜索提供纯文本提取 —— 它必须返回和 renderToolResultMessage 实际渲染内容一致的文本(源码中通过 transcriptSearch.renderFidelity.test.tsx 测试检测 phantom 文本和 under-count 漂移)。
七、可迁移的设计模式
模式 1:语义化颜色 Token + 命名约束
将颜色定义为语义化 token(success、error),而非 green、red。对于受限用途的颜色,使用”尖叫式”命名(_FOR_SUBAGENTS_ONLY)在类型层面防止误用。
适用场景:任何需要主题化的应用。在 Web 中对应 CSS custom properties / design tokens。
模式 2:Preview-Save-Cancel 三态
用户选择器(主题、模型等)使用 preview/save/cancel 三态模式:预览时实时应用但不持久化,确认才写入,取消则恢复。避免用户在浏览选项时意外修改配置。
适用场景:任何设置面板中的选择器。
模式 3:Opt-in 交互注册
当新的交互行为(如双层焦点)可能与旧组件冲突时,使用 opt-in 注册机制:只有主动调用 Hook 的组件才激活新行为,旧组件保持原有行为。这比 opt-out(默认开启、需要主动关闭)更安全。
适用场景:向后兼容的交互升级、渐进式功能迁移。
下一篇预告
我们将深入 CLAUDE.md 发现链、自动记忆系统(memdir/)和 Session Memory,看看 Claude Code 如何让 AI 跨会话记住你的偏好和项目上下文。
全部内容请关注 https://github.com/luyao618/Claude-Code-Source-Study (求一颗免费的小星星)