你有没有想过,在终端里用 React 写 UI 是什么样的体验?Claude Code CLI 就做到了,它使用 Ink 框架把 React 组件渲染到终端。今天我们来探索这个有趣的 UI 系统。
Ink 是什么?
Ink 是一个 React 终端渲染器,它提供了类似 React DOM 的 API,但输出的是 ANSI 终端指令而不是 HTML。你可以用熟悉的 React 概念(组件、props、hooks、context)来构建终端界面。
import { Box, Text } from 'ink'
function MyComponent() {
return (
<Box flexDirection="column">
<Text color="green">Hello, Terminal!</Text>
</Box>
)
}
REPL.tsx:最大的文件
src/screens/REPL.tsx 是整个项目中最大的文件(约 900KB)。它负责主交互界面,包括:
- 消息流管理
- 工具执行状态
- 权限请求处理
- 会话恢复
状态管理的复杂性
看看 REPL 的状态声明:
const [messages, setMessages] = useState<Message[]>([])
const [isLoading, setIsLoading] = useState(false)
const [verbose, setVerbose] = useState(false)
const [input, setInput] = useState('')
const [mode, setMode] = useState<PromptInputMode>('prompt')
const [toolPermissionContext, setToolPermissionContext] = useState<ToolPermissionContext>(...)
const [resumeEntrypoint, setResumeEntrypoint] = useState<ResumeEntrypoint>()
这些状态之间有复杂的依赖关系。比如 mode 的变化会影响 input 的处理方式,toolPermissionContext 会影响工具执行的行为。
条件 Feature Flags 导入
REPL 中有很多条件导入,利用 Bun 的编译时特性标志:
const useVoiceIntegration = feature('VOICE_MODE')
? require('../hooks/useVoiceIntegration.js').useVoiceIntegration
: () => ({ stripTrailing: () => 0, handleKeyEvent: () => {} })
const useProactive = feature('PROACTIVE')
? require('../proactive/useProactive.js').useProactive
: null
如果 VOICE_MODE 未启用,这段代码会被删除,useVoiceIntegration 变成一个空实现。这避免了加载不必要的模块。
Messages.tsx:消息渲染的魔法
消息列表是最复杂的 UI 部分。src/components/Messages.tsx 负责:
- 消息分组和折叠
- 搜索过滤
- 虚拟列表调度
消息过滤
有一个 Brief 模式的过滤函数:
export function filterForBriefTool<T>(messages: T[], briefToolNames: string[]): T[] {
const briefToolUseIDs = new Set<string>()
return messages.filter(msg => {
if (msg.type === 'system') return msg.subtype !== 'api_metrics'
// 只保留 Brief tool_use 和相关 tool_result
...
})
}
Brief 模式是一种精简显示模式,只保留关键工具调用,隐藏大量中间消息。
消息分组
连续的工具调用会被合并显示:
applyGrouping(messages, options)
collapseBackgroundBashNotifications(messages)
collapseHookSummaries(messages)
collapseReadSearchGroups(messages)
这些折叠逻辑减少了视觉噪音,让用户更容易追踪对话主线。
VirtualMessageList.tsx:虚拟滚动
终端 UI 的虚拟滚动是个挑战。Claude Code 实现了自己的虚拟列表组件:
type Props = {
messages: RenderableMessage[]
scrollRef: RefObject<ScrollBoxHandle | null>
columns: number // 宽度变化时清除高度缓存
itemKey: (msg: RenderableMessage) => string
renderItem: (msg: RenderableMessage, index: number) => React.ReactNode
// ... 搜索和跳转相关
}
高度缓存策略
终端中每行的高度可能不同(有些是单行文本,有些是多行代码块)。虚拟列表需要知道每项的高度才能正确滚动:
useEffect(() => {
heightCache.clear()
}, [columns])
当终端宽度变化时,所有高度缓存失效,因为内容会重新换行。
粘性提示跟踪
一个有趣的功能是"粘性提示"——当你向上滚动查看历史时,当前正在输入的内容会"粘"在屏幕底部:
type StickyPrompt = {
text: string
scrollTo: () => void
} | 'clicked'
用户可以点击粘性提示跳回输入位置。
PromptInput.tsx:输入组件的复杂性
输入组件是用户交互的核心,它处理:
- 多种输入模式(prompt/bash/memory)
- @ 提议和 / 命令建议
- 图片粘贴
- 历史导航
输入模式切换
type PromptInputMode =
| 'prompt' // 默认提示模式
| 'bash' // Bash 命令模式 (!)
| 'memory' // 内存模式 (@)
| 'vim' // Vim 模式
当用户输入 ! 开头时,切换到 bash 模式,输入会被当作 shell 命令执行。
建议触发器
输入组件持续检测各种触发器:
const atMentionPositions = findBtwTriggerPositions(input, cursorOffset)
const slashPositions = findSlashCommandPositions(input, cursorOffset)
const thinkingPositions = findThinkingTriggerPositions(input)
const budgetPositions = findTokenBudgetPositions(input, cursorOffset)
当用户输入 / 时,会显示命令建议列表;输入 @ 时,会显示 memory/btw 建议。
Context 层级
UI 的 Context 层级设计很有层次:
<FpsMetricsProvider getFpsMetrics={getFpsMetrics}>
<StatsProvider store={stats}>
<AppStateProvider initialState={initialState}>
<MailboxProvider>
<VoiceProvider> {/* 条件 */}
{children}
</VoiceProvider>
</MailboxProvider>
</AppStateProvider>
</StatsProvider>
</FpsMetricsProvider>
每一层 Context 负责特定的状态领域:
- FpsMetrics:性能指标
- Stats:统计信息
- AppState:全局应用状态
- Mailbox:团队协作消息
- Voice:语音模式(条件)
React Compiler 的应用
Claude Code 使用了 React Compiler,这是一个自动 memoization 工具。它会分析组件代码,自动添加 memo 和 useMemo,减少手动优化的负担。
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
if (feature('AWAY_SUMMARY')) {
useAwaySummary(messages, setMessages, isLoading)
}
这里的 biome-ignore 注释很有意思。Biome lint 规则要求 hooks 在顶层调用,但 feature() 是编译时常量,所以条件分支不影响 hooks 规则。
总结
Claude Code 的终端 UI 系统展示了几个有趣的设计:
- Ink + React:把熟悉的 React 概念带到终端
- 虚拟滚动:处理大型消息列表的高效渲染
- 消息分组折叠:减少视觉噪音,提升可读性
- 多模式输入:prompt/bash/memory/vim 的灵活切换
- Context 层级:清晰的状态领域划分
- React Compiler:自动 memoization 减少手动优化
下一篇我会分享命令系统,看看斜杠命令是如何设计和实现的。