五、深入 Claude Code CLI 源码:终端 UI 的 React 实现

0 阅读4分钟

你有没有想过,在终端里用 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 系统展示了几个有趣的设计:

  1. Ink + React:把熟悉的 React 概念带到终端
  2. 虚拟滚动:处理大型消息列表的高效渲染
  3. 消息分组折叠:减少视觉噪音,提升可读性
  4. 多模式输入:prompt/bash/memory/vim 的灵活切换
  5. Context 层级:清晰的状态领域划分
  6. React Compiler:自动 memoization 减少手动优化

下一篇我会分享命令系统,看看斜杠命令是如何设计和实现的。