第 22 篇:设计系统 — 终端 UI 的组件化实践

为什么终端应用需要设计系统?

当你用 console.log 输出几行文字时,不需要设计系统。但当你的终端应用有流式输出、多工具并行进度条、权限确认对话框、Tab 切换面板、模糊搜索选择器、dark/light 主题切换时,情况就完全不同了。

Claude Code 面对的正是这样的复杂度。它的 UI 不是静态的信息打印,而是一个高度动态的交互界面。在第 21 篇中我们看到了 forked Ink 框架如何在终端中运行 React;本篇则关注在这个框架之上,团队如何构建了一套可复用的组件化设计系统

这套设计系统回答三个核心问题:

  1. 颜色从哪来? — 6 套主题 × 80+ 语义化颜色 token 的主题系统
  2. 组件怎么组合? — 15 个设计系统组件 + 1 个颜色工具函数的职责划分
  3. 工具 UI 怎么统一? — Tool 接口中 10 个 render/查询方法构成的 UI 协议

一、主题系统:80+ 语义化颜色 token

1.1 Theme 类型:颜色的语义化契约

终端应用的颜色管理比 Web 更复杂。Web 有 CSS 变量和成熟的设计 token 体系,终端只有 ANSI 转义序列。Claude Code 的解法是定义一个 Theme 类型作为颜色的语义化契约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// utils/theme.ts:4-89
export type Theme = {
// 品牌色
claude: string // Claude 橙色
claudeShimmer: string // 微光效果用的浅色变体
permission: string // 权限确认色(蓝紫色系)
planMode: string // Plan 模式色

// 文本色
text: string // 主文本
inverseText: string // 反色文本
inactive: string // 非活跃/禁用
subtle: string // 微弱提示
suggestion: string // 建议高亮

// 语义色
success: string // 成功(绿)
error: string // 错误(红)
warning: string // 警告(琥珀)

// Diff 色(4 个梯度 + 2 个词级高亮)
diffAdded: string
diffRemoved: string
diffAddedDimmed: string
diffRemovedDimmed: string
diffAddedWord: string
diffRemovedWord: string

// Agent 色板(8 色,用于区分多个并行 Agent)
red_FOR_SUBAGENTS_ONLY: string
blue_FOR_SUBAGENTS_ONLY: string
// ... 还有 green, yellow, purple, orange, pink, cyan

// 彩虹色(用于 ultrathink 关键词动画,7 色 + 7 shimmer)
rainbow_red: string
rainbow_red_shimmer: string
// ...
}

几个值得注意的设计决策:

命名即约束red_FOR_SUBAGENTS_ONLY 这种”尖叫式”命名不是偶然的。它在类型层面告诉消费者:这个颜色只该用于子 Agent 的视觉区分,不要在其他地方用红色。这比注释更有效 —— 你在代码审查中会立刻注意到 theme.red_FOR_SUBAGENTS_ONLY 出现在非 Agent 的上下文中。

Shimmer 配对:几乎每个主色都有一个对应的 *Shimmer 变体(更浅的版本)。这是为微光动画效果准备的 —— 在两个颜色之间交替切换产生闪烁效果。

80+ 字段:整个 Theme 类型有 80 多个颜色字段,覆盖了从 Diff 高亮到速率限制进度条的所有场景。

1.2 六套主题:从 True Color 到 16 色 ANSI

1
2
3
4
5
6
7
8
9
// utils/theme.ts:91-98
export const THEME_NAMES = [
'dark',
'light',
'light-daltonized',
'dark-daltonized',
'light-ansi',
'dark-ansi',
] as const

六套主题按两个维度组合:

维度 选项 说明
明暗 dark / light 跟随终端背景色
色彩能力 标准 / daltonized / ansi 24-bit RGB / 色盲友好 / 仅 16 色

标准主题使用显式 RGB 值(如 'rgb(215,119,87)')。源码注释明确说明原因:

1
2
3
4
5
// utils/theme.ts:112-115
/**
* Light theme using explicit RGB values to avoid inconsistencies
* from users' custom terminal ANSI color definitions
*/

用户可能把终端的”红色”设成了粉色,所以不能依赖 ANSI 命名色。

ANSI 主题使用 'ansi:red''ansi:blueBright' 等命名色,适配不支持 True Color 的终端。

Daltonized(色盲友好)主题最值得一提 —— 它不是简单地调整饱和度,而是重新选择语义映射

1
2
3
// utils/theme.ts:380-381
// 色盲友好模式:success 不用绿色,改用蓝色
success: 'rgb(0,102,153)', // Blue instead of green for deuteranopia

对于红绿色盲用户,”成功=绿色”是无法区分的。色盲主题将 success 映射到蓝色,error 保持纯红 —— 蓝和红对 deuteranopia 用户来说对比足够大。

1.3 Auto 主题:运行时检测终端明暗

1
2
3
4
5
6
7
8
// utils/theme.ts:103-109
export const THEME_SETTINGS = ['auto', ...THEME_NAMES] as const

/**
* A theme preference as stored in user config. `'auto'` follows the system
* dark/light mode and is resolved to a ThemeName at runtime.
*/
export type ThemeSetting = (typeof THEME_SETTINGS)[number]

用户配置中可以选 'auto',但实际渲染时必须解析为具体的 ThemeName。这个解析发生在 ThemeProvider 中:

1
2
3
// components/design-system/ThemeProvider.tsx:81
const currentTheme: ThemeName =
activeSetting === 'auto' ? systemTheme : activeSetting

auto 模式检测的是终端的实际背景色,而非操作系统的 appearance 设置 —— 一个 dark 终端运行在 light-mode 的 macOS 上,仍然应该解析为 'dark'。具体机制分两步(utils/systemTheme.ts):

  1. 同步猜测:启动时读取 $COLORFGBG 环境变量(rxvt 约定的 fg;bg 格式,部分终端如 iTerm2、Konsole 会设置),根据 ANSI 颜色索引判断明暗(0-6 和 8 是 dark,7 和 9-15 是 light)。
  2. 异步校正ThemeProvider 中的 watchSystemTheme 通过 OSC 11 终端查询协议获取真实背景色 RGB 值,用 ITU-R BT.709 相对亮度公式判断明暗(luminance > 0.5 为 light),校正模块级缓存。

当用户在运行期间切换了终端的明暗配色,OSC 11 watcher 会捕捉到变化并更新 UI。

1.4 Apple Terminal 兼容:256 色降级

1
2
3
4
5
// utils/theme.ts:616-619
const chalkForChart =
env.terminal === 'Apple_Terminal'
? new Chalk({ level: 2 }) // 256 colors
: chalk

Apple Terminal 不能正确处理 24-bit 颜色转义序列,所以针对它创建了一个降级的 chalk 实例,只使用 256 色模式。这种按终端逐个适配的做法在终端应用开发中很常见。


二、ThemeProvider:React Context 桥接

主题系统的 React 层由 ThemeProvider + useTheme 组成,遵循经典的 Context 模式,但有几个终端特有的设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// components/design-system/ThemeProvider.tsx:43-116
export function ThemeProvider({ children, initialState, onThemeSave }) {
const [themeSetting, setThemeSetting] = useState(
initialState ?? defaultInitialTheme
)
const [previewTheme, setPreviewTheme] = useState<ThemeSetting | null>(null)
const [systemTheme, setSystemTheme] = useState<SystemTheme>(...)

// Preview 优先于 saved setting
const activeSetting = previewTheme ?? themeSetting

// 'auto' → 解析为具体的 ThemeName
const currentTheme: ThemeName =
activeSetting === 'auto' ? systemTheme : activeSetting

const value = useMemo(() => ({
themeSetting,
setThemeSetting,
setPreviewTheme, // 主题选择器预览
savePreview, // 确认预览
cancelPreview, // 取消预览
currentTheme, // 最终渲染用的主题(永远不是 'auto')
}), [...])

return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}

预览机制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 颜色解析的统一入口

ThemedTextThemedBox 是整个设计系统的两个基石组件。它们的核心职责只有一个:将 Theme key 解析为实际颜色值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// components/design-system/ThemedText.tsx:66-74
function resolveColor(
color: keyof Theme | Color | undefined,
theme: Theme
): Color | undefined {
if (!color) return undefined
// 原始颜色值直通
if (color.startsWith('rgb(') || color.startsWith('#') ||
color.startsWith('ansi256(') || color.startsWith('ansi:')) {
return color as Color
}
// Theme key → 实际颜色值
return theme[color as keyof Theme] as Color
}

resolveColor 函数支持双通道输入:既接受 Theme key(如 'success'),也接受原始颜色值(如 'rgb(0,255,0)')。这让组件既能享受主题化,又能在必要时绕过主题。

ThemedText 在此基础上增加了两个能力:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// components/design-system/ThemedText.tsx:80-108
export default function ThemedText({
color, backgroundColor, dimColor, bold, italic, ...
}: Props) {
const [themeName] = useTheme()
const theme = getTheme(themeName)
const hoverColor = useContext(TextHoverColorContext)

// 颜色优先级:explicit color > hoverColor > dimColor
const resolvedColor = !color && hoverColor
? resolveColor(hoverColor, theme)
: dimColor
? (theme.inactive as Color)
: resolveColor(color, theme)

return <Text color={resolvedColor} ...>{children}</Text>
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// components/design-system/color.ts:9-30
export function color(
c: keyof Theme | Color | undefined,
theme: ThemeName,
type: ColorType = 'foreground',
): (text: string) => string {
return text => {
if (!c) return text
// 原始颜色直通
if (c.startsWith('rgb(') || ...) {
return colorize(text, c, type)
}
// Theme key 查表
return colorize(text, getTheme(theme)[c as keyof Theme], type)
}
}

这是一个柯里化函数:先传入颜色和主题,返回一个 string → string 的着色函数。这样可以预创建着色器,在循环中复用。


四、布局组件:Pane、Divider、Dialog

4.1 Pane:斜杠命令的标准容器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// components/design-system/Pane.tsx:33-76
export function Pane({ children, color }: PaneProps) {
// 在 Modal 内时,跳过自己的 Divider(避免双边框)
if (useIsInsideModal()) {
return (
<Box flexDirection="column" paddingX={1} flexShrink={0}>
{children}
</Box>
)
}
return (
<Box flexDirection="column" paddingTop={1}>
<Divider color={color} />
<Box flexDirection="column" paddingX={2}>
{children}
</Box>
</Box>
)
}

Pane 是所有斜杠命令屏幕(/config/help/plugins 等)的标准容器。它的设计体现了上下文感知:在 Modal 内渲染时自动省略 Divider,因为 Modal 自己有边框。源码注释详细说明了选择理由,甚至引用了 Issue 编号(#23592)解释 flexShrink={0} 的必要性。

4.2 Divider:全宽分隔线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// components/design-system/Divider.tsx:66-148
export function Divider({ width, color, char = '─', padding = 0, title }) {
const { columns: terminalWidth } = useTerminalSize()
const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding)

if (title) {
// ─────────── Title ───────────
const titleWidth = stringWidth(title) + 2
const sideWidth = Math.max(0, effectiveWidth - titleWidth)
const leftWidth = Math.floor(sideWidth / 2)
const rightWidth = sideWidth - leftWidth
return (
<Text color={color} dimColor={!color}>
{char.repeat(leftWidth)} <Text dimColor>{title}</Text> {char.repeat(rightWidth)}
</Text>
)
}
return <Text color={color} dimColor={!color}>{char.repeat(effectiveWidth)}</Text>
}

Divider 支持带标题的居中分隔(如 ─── 3 new messages ───),并处理了 ANSI 字符宽度计算(stringWidth 而非 title.length,因为 ANSI 转义序列不占显示宽度)。

4.3 Dialog:确认/取消对话框

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// components/design-system/Dialog.tsx:30-137
export function Dialog({
title, subtitle, children, onCancel,
color = 'permission',
hideInputGuide, hideBorder,
inputGuide, isCancelActive = true,
}) {
const exitState = useExitOnCtrlCDWithKeybindings(...)

// 可配置的快捷键绑定
useKeybinding('confirm:no', onCancel, {
context: 'Confirmation',
isActive: isCancelActive,
})

// ...
if (hideBorder) return content
return <Pane color={color}>{content}</Pane>
}

几个设计亮点:

  • isCancelActive 开关:当 Dialog 内嵌了 TextInput 时,需要临时禁用 Dialog 的 Esc/n 快捷键,否则用户输入 ‘n’ 会被 Dialog 捕获为取消。这是终端 UI 中常见的快捷键冲突管理问题。
  • hideBorder 支持:当 Dialog 嵌套在 Pane 内时,可以隐藏自己的边框,避免双重边框。
  • 默认 color='permission':权限确认是最常见的 Dialog 场景,蓝紫色成为默认色。

五、交互组件:Tabs、ProgressBar、FuzzyPicker

5.1 Tabs:三层焦点协作模型

Tabs 组件是设计系统中最复杂的组件,它实现了一个三层焦点协作模型

1
2
3
4
5
6
┌─ Header focused ─────────────────────┐
│ Model [Config] Permissions Stats │ ← Tab/←/→ 切换标签
├──────────────────────────────────────┤
│ Content area │ ← ↓ 进入内容(需 opt-in)
│ (Select list, form, etc.) │ ← ↑ 回到 Header
└──────────────────────────────────────┘

源码中实际存在三层控制(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
2
// components/design-system/ProgressBar.tsx:26
const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█']

进度条使用 9 个 Unicode block 字符实现亚字符精度。一个字符的宽度被分成 8 级(1/8 字符精度):

1
2
3
4
5
6
7
8
9
// ProgressBar 核心逻辑
const whole = Math.floor(ratio * width) // 完整填充的字符数
const remainder = ratio * width - whole // 尾部的小数部分
const middle = Math.floor(remainder * BLOCKS.length) // 映射到 0-8
segments = [
BLOCKS[8].repeat(whole), // █████
BLOCKS[middle], // ▌ (部分填充)
BLOCKS[0].repeat(empty), // 空白
]

这比简单的 [==== ] 风格进度条精确 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// Tool.ts:566-694(完整协议,非简化)
interface Tool {
// ── 必须实现 ──
// 渲染工具调用(注意 input 是 Partial,因为流式传输时参数可能不完整)
renderToolUseMessage(
input: Partial<Input>,
options: { theme: ThemeName; verbose: boolean; commands?: Command[] }
): React.ReactNode

// ── 结果渲染(可选) ──
// 渲染工具执行结果。options 中包含丰富的上下文:
// tools(工具集合)、isTranscriptMode、isBriefOnly、input(原始请求)
renderToolResultMessage?(
content: Output,
progressMessages: ProgressMessage[],
options: {
style?: 'condensed'; theme: ThemeName; tools: Tools
verbose: boolean; isTranscriptMode?: boolean
isBriefOnly?: boolean; input?: unknown
}
): React.ReactNode

// ── 进度与状态(可选) ──
renderToolUseProgressMessage?(...) // 执行中的实时进度
renderToolUseQueuedMessage?() // 排队等待执行

// ── 异常态渲染(可选) ──
// 用户拒绝时的自定义 UI(如文件编辑工具展示被拒绝的 diff)
renderToolUseRejectedMessage?(input, options): React.ReactNode
// 执行出错时的自定义 UI(如搜索工具展示 "File not found")
renderToolUseErrorMessage?(result, options): React.ReactNode

// ── 聚合渲染(可选) ──
// 多个并行的同类工具调用合并为一个分组渲染(仅 non-verbose 模式)
renderGroupedToolUse?(toolUses: Array<{
param: ToolUseBlockParam
isResolved: boolean; isError: boolean; isInProgress: boolean
progressMessages: ProgressMessage[]
result?: { param: ToolResultBlockParam; output: unknown }
}>, options): React.ReactNode | null

// ── 元数据与搜索(可选) ──
renderToolUseTag?(input) // 工具调用旁的标签(超时、模型等)
isResultTruncated?(output): boolean // 非 verbose 是否截断(控制展开 affordance)
extractSearchText?(out): string // Transcript 搜索索引用的纯文本提取
}

相比”概念上 6 个方法”的简化理解,实际协议覆盖了正常态、异常态、聚合态、搜索索引四个维度,共 10 个方法。几个重要的设计原则:

Partial InputrenderToolUseMessage 接收的是 Partial<Input> 而非 Input。这是因为流式传输时,工具参数可能还没有完全到达 —— 比如 BashTool 可能先收到 command 字段,timeout 字段还在路上。组件必须能优雅地处理不完整输入。

verbose 模式:多数 render 方法都接收 verbose 参数。非 verbose 模式显示精简信息(如只显示文件名),verbose 模式显示完整信息(如完整的 diff)。isResultTruncated 方法告诉 UI 框架是否需要展示”点击展开”的交互提示。

丰富的 options 上下文renderToolResultMessage 的 options 不仅有 themeverbose,还传入了 tools(当前工具集合)、isTranscriptMode(是否在 transcript 回放中)、isBriefOnly(brief 模式)、input(原始调用参数,用于结果摘要引用请求内容)。这让工具 UI 可以根据不同渲染场景自适应。

theme 透传:渲染方法直接接收 ThemeName 而非从 Context 中获取。这让工具 UI 可以在 React 树之外渲染(如 transcript 导出场景)。

Fallback 降级renderToolUseRejectedMessagerenderToolUseErrorMessage 都是可选的,省略时使用 <FallbackToolUseRejectedMessage /><FallbackToolUseErrorMessage /> 通用组件。只有需要自定义异常态 UI 的工具(如文件编辑工具展示被拒绝的 diff)才需要实现。

搜索索引协同extractSearchText 为 transcript 搜索提供纯文本提取 —— 它必须返回和 renderToolResultMessage 实际渲染内容一致的文本(源码中通过 transcriptSearch.renderFidelity.test.tsx 测试检测 phantom 文本和 under-count 漂移)。


七、可迁移的设计模式

模式 1:语义化颜色 Token + 命名约束

将颜色定义为语义化 token(successerror),而非 greenred。对于受限用途的颜色,使用”尖叫式”命名(_FOR_SUBAGENTS_ONLY)在类型层面防止误用。

适用场景:任何需要主题化的应用。在 Web 中对应 CSS custom properties / design tokens。

模式 2:Preview-Save-Cancel 三态

用户选择器(主题、模型等)使用 preview/save/cancel 三态模式:预览时实时应用但不持久化,确认才写入,取消则恢复。避免用户在浏览选项时意外修改配置。

适用场景:任何设置面板中的选择器。

模式 3:Opt-in 交互注册

当新的交互行为(如双层焦点)可能与旧组件冲突时,使用 opt-in 注册机制:只有主动调用 Hook 的组件才激活新行为,旧组件保持原有行为。这比 opt-out(默认开启、需要主动关闭)更安全。

适用场景:向后兼容的交互升级、渐进式功能迁移。


下一篇预告

第 23 篇:Memory 系统 — AI 记忆的多层架构

我们将深入 CLAUDE.md 发现链、自动记忆系统(memdir/)和 Session Memory,看看 Claude Code 如何让 AI 跨会话记住你的偏好和项目上下文。


全部内容请关注 https://github.com/luyao618/Claude-Code-Source-Study (求一颗免费的小星星)