跳转至

阶段 2 | REPL 主循环

目标:理解 Claude Code 的核心交互循环——用户键入 → 提交 → 流式响应 → 渲染反馈——在 5005 行的 REPL.tsx 里是怎么组织的。 时长:2~3 天 前端类比:整个 <App /> 根组件(5005 行),但宿主从 <div id="root"> 换成了 TTY。


2.1 为什么 REPL 这么大

REPL.tsx              5005 行
PromptInput.tsx       2338 行
VirtualMessageList    1081 行
hooks/                85 个文件

REPL 巨型化的根因是 Claude Code 把"状态派生 + 副作用编排 + UI 渲染"三层都集中在一个组件里。对前端工程师来说,相当于把 <App /> 写 5000 行——坏味道,但受限于 TUI 框架的能力(Ink 没有 React Server Components、没有好的代码分割机制),不得不如此。

阅读时的心态:别试图顺序读,按"数据流"切分

2.2 顶层结构

// src/screens/REPL.tsx:572
export function REPL({ /* props */ }) { ... }

REPL 函数体可粗分为 6 段:

行号区间 内容 性质
~580-800 State Hooks 块 useState/useRef/useMemo 声明 30+ 个本地 state
~800-1100 Custom Hooks 块 调用 ~40 个 use* 钩子编排副作用
~1100-1500 派生状态 / 选择器 useMemo 计算 from state
~1500-2500 事件处理函数 onSubmit / onCancel / onAbort / onToolConfirm 等回调
~2500-4500 子组件树渲染 JSX 返回,组装 Box/Text/子组件
~4500-5000 辅助渲染分支 各种 dialog、modal、status bar 条件渲染

💡 不要从 1 行读到 5000 行。先看 imports(第 1~120 行)+ props 类型(约 572~620 行)+ JSX return(约 4200~4500 行)三段,画出"输入什么、状态什么、输出什么"。

2.3 核心 imports 透出的依赖图

REPL.tsx 的 import 块(第 1~120 行)是整个项目的依赖汇总

2.3.1 渲染层(Ink)

import { Box, Text, useStdin, useTheme, useTerminalFocus, useTerminalTitle, useTabStatus } from '../ink.js';
import { useInput } from '../ink.js';

ink.js 暴露的 React 风格 hook: - useInput(handler) —— 监听键盘事件,相当于 Web 的 onKeyDown 全局监听 - BoxText —— 终端里的 <div><span>,支持 flex 布局 - useStdin —— 拿到 raw stdin(用于图片粘贴等) - useTheme —— 当前主题色 - useTerminalFocus —— 终端窗口是否聚焦 - useTabStatus —— Tab 状态

2.3.2 输入层

import PromptInput from '../components/PromptInput/PromptInput.js';
import { PromptInputQueuedCommands } from '../components/PromptInput/PromptInputQueuedCommands.js';
import { prependModeCharacterToInput } from '../components/PromptInput/inputModes.js';
  • PromptInput(2338 行)—— 输入框本体
  • inputModes.ts —— 输入模式:默认、命令、shell、vim、voice ...

2.3.3 状态层

import { ... } from '../bootstrap/state.js';  // 全局会话状态
import { ... } from '../cost-tracker.js';
import { useCostSummary } from '../costHook.js';

2.3.4 副作用层(hooks)

import { useLogMessages } from '../hooks/useLogMessages.js';
import { useReplBridge } from '../hooks/useReplBridge.js';
import { useRemoteSession } from '../hooks/useRemoteSession.js';
import { useDirectConnect } from '../hooks/useDirectConnect.js';
import { useSSHSession } from '../hooks/useSSHSession.js';
import { useAssistantHistory } from '../hooks/useAssistantHistory.js';
import { useIdeLogging } from '../hooks/useIdeLogging.js';
import { useApiKeyVerification } from '../hooks/useApiKeyVerification.js';
import { useSkillImprovementSurvey } from '../hooks/useSkillImprovementSurvey.js';
import { useAfterFirstRender } from '../hooks/useAfterFirstRender.js';
import { useDeferredHookMessages } from '../hooks/useDeferredHookMessages.js';
import { useBackgroundTaskNavigation } from '../hooks/useBackgroundTaskNavigation.js';
import { useSwarmInitialization } from '../hooks/useSwarmInitialization.js';
import { useTeammateViewAutoExit } from '../hooks/useTeammateViewAutoExit.js';

use* 命名的 hooks 全部放在 src/hooks/,是 REPL 的"业务编排"层。每个 hook 通常负责一个独立 concern。

2.3.5 键位层

import { GlobalKeybindingHandlers } from '../hooks/useGlobalKeybindings.js';
import { CommandKeybindingHandlers } from '../hooks/useCommandKeybindings.js';
import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js';
import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js';
import { getShortcutDisplay } from '../keybindings/shortcutFormat.js';
import { CancelRequestHandler } from '../hooks/useCancelRequest.js';
  • useGlobalKeybindings —— 全局快捷键(Ctrl+C 退出、Ctrl+L 清屏等)
  • useCommandKeybindings —— 命令模式快捷键
  • KeybindingSetup —— 全局键位 Provider

2.3.6 工具/权限层

import { PermissionRequest, type ToolUseConfirm } from '../components/permissions/PermissionRequest.js';
import { WorkerPendingPermission } from '../components/permissions/WorkerPendingPermission.js';
import { ElicitationDialog } from '../components/mcp/ElicitationDialog.js';
import { PromptDialog } from '../components/hooks/PromptDialog.js';

2.3.7 上下文/通知层

import { useNotifications } from '../context/notifications.js';
import { sendNotification } from '../services/notifier.js';
import { useFpsMetrics } from '../context/fpsMetrics.js';
import { useTerminalNotification } from '../ink/useTerminalNotification.js';

💡 REPL 的 import 列表就是"项目功能矩阵"。读懂这些 import,等于读懂了 Claude Code 的全部能力。

2.4 三大数据流

把 REPL 想象成一个有 3 条数据流的"Web 应用":

┌─────────────────────────────────────────────────────────────────┐
│  输入流  PromptInput → REPL.handleSubmit                         │
│    ↓                                                            │
│  状态流  AppState.setState() → store 通知 listeners               │
│    ↓                                                            │
│  输出流  AppState.getState() → 子组件 re-render → Ink → TTY      │
└─────────────────────────────────────────────────────────────────┘

2.4.1 输入流:用户键入 → 提交

链路: 1. 用户键入 → Ink useInput 触发 → PromptInput 维护本地 input buffer 2. 用户按 Enter → PromptInputonSubmit(text) 回调 3. REPL 的 handleSubmit 接收 → 构造 UserMessage → push 到 AppState 4. 调用 query() 发起 agent 循环

关键文件: - src/components/PromptInput/PromptInput.tsx(2338 行) - src/hooks/useInputBuffer.ts(输入缓冲区管理) - src/hooks/useCommandKeybindings.tsx(命令模式快捷键)

PromptInput 内部组件结构(看 2338 行的拆分):

PromptInput.tsx
├── HistorySearchInput.tsx    (Ctrl+R 历史搜索)
├── inputModes.ts             (模式定义: normal/shell/vim/...)
├── inputPaste.ts             (粘贴大段文本处理)
├── IssueFlagBanner.tsx       (错误提示条)
├── Notifications.tsx         (输入框上方通知)
├── PromptInputFooter.tsx     (底部 hint 栏)
│   ├── PromptInputFooterLeftSide.tsx
│   ├── PromptInputFooterSuggestions.tsx
│   └── PromptInputHelpMenu.tsx
├── PromptInputModeIndicator.tsx  (显示当前模式)
├── PromptInputQueuedCommands.tsx (排队中的命令)
├── PromptInputStashNotice.tsx
├── ShimmeredInput.tsx        (流光动效输入)
├── usePromptInputPlaceholder.ts
├── useShowFastIconHint.ts
├── useSwarmBanner.ts
├── useMaybeTruncateInput.ts
└── VoiceIndicator.tsx

💡 学习技巧:先看 PromptInput.tsx 顶部的 interface PromptInputProps,了解它接什么 props。Props 决定 API 边界

2.4.2 状态流:副作用 + 派生

链路: 1. useLogMessages 监听 store 的消息变化 2. useReplBridge 维护 REPL ↔ IDE/Remote 的桥接 3. useRemoteSession / useSSHSession 处理远程会话 4. useApiKeyVerification 定期检查 API key 有效性 5. useAfterFirstRender 第一次渲染后的初始化

每个 use* hook 都是"独立 concern": - 单一职责——只关心一件事 - 挂载即订阅——useEffect 内部 store.subscribe() - 卸载即清理——返回 cleanup 函数

💡 这正是 Web 项目 useEffect 的最佳实践——只是 Claude Code 把它标准化成了 85 个具名 hook。

2.4.3 输出流:渲染反馈

链路: 1. AppState 变化(store 通知 listeners) 2. REPL 通过 useLogMessages 拿到 messages 3. 传给 <VirtualMessageList messages={...} /> 4. VirtualMessageList 渲染可见窗口(虚拟滚动) 5. 每条 message 用对应的 components/messages/*Message.tsx 渲染

关键文件: - src/components/VirtualMessageList.tsx(1081 行)—— 长会话虚拟化 - src/components/messages/*(21 个)—— 消息渲染器 - src/components/Messages.tsx + Message.tsx + MessageRow.tsx + MessageModel.tsx —— 消息管线

VirtualMessageList 的关键设计(前端重点): - 维护一个 JumpHandle ref - 暴露 jumpToMessage / scrollToBottom / scrollToTop 给父组件 - 内部用 useLayoutEffect 测量每条消息高度 - 用 cache(createFileStateCacheWithSizeLimit)缓存已渲染消息的高度

// src/screens/REPL.tsx 顶部
import type { JumpHandle } from '../components/VirtualMessageList.js';

REPL 用 useRef<JumpHandle> 拿到句柄,可以在用户按 PgDn/PgUp 时调用 jump。

2.5 关键交互:消息流

消息在 AppState 里是个数组(推测),每条 message 有 type 字段,对应不同的渲染器:

Message Type 渲染器文件 内容
user AttachmentMessage.tsx + 主消息组件 用户输入(含附件)
assistant AssistantTextMessage.tsx / AssistantThinkingMessage.tsx / AssistantToolUseMessage.tsx LLM 回复(文本/思考/工具调用)
attachment AttachmentMessage.tsx 图片、PDF 等
tool_use FileEditToolDiff.tsx / BashToolDiff.tsx 工具调用展示
tool_result 各种 *ToolResult*.tsx 工具执行结果
plan_approval PlanApprovalMessage.tsx Plan Mode 审批
compact_boundary CompactBoundaryMessage.tsx 上下文压缩分界点
rate_limit RateLimitMessage.tsx 限流提示
task_assignment TaskAssignmentMessage.tsx 多 agent 任务分发
system_* SystemTextMessage.tsx / SystemAPIErrorMessage.tsx 系统消息
shutdown ShutdownMessage.tsx 关闭

💡 这种 dispatch 模式在 Web 项目里也很常见:一份数据数组 + type-to-component map,避免大量 if/else。

2.6 键位系统详解

目录src/keybindings/ 文件数:~10 个

2.6.1 三层结构

GlobalKeybindingHandlers    全局(Ctrl+C 取消、Ctrl+L 清屏...)
CommandKeybindingHandlers   命令模式(输入 `/` 后的快捷键)
useInput from ink.js        兜底(单字符快捷键如 Esc、g、G)

2.6.2 关键文件

  • src/keybindings/KeybindingProviderSetup.tsx —— 把所有快捷键注册到全局
  • src/keybindings/useShortcutDisplay.ts —— 把快捷键转成展示文案("⌘C" vs "^C")
  • src/keybindings/shortcutFormat.ts —— 跨平台格式化

2.6.3 为什么不用 mousetrap 那种库?

Claude Code 用了自研的 keybinding 系统,因为: - 需要支持 chord 组合(先按 Ctrl+K 再按 Ctrl+S) - 需要 context-aware(在输入框里 Esc 是取消输入,在历史模式是退出) - 需要 可发现性——所有快捷键都能查到提示文案

src/keybindings/ 目录就是 TUI 版的"键盘事件总线"。

2.7 子组件树

REPL 渲染的子组件(推测层级):

<REPL>
  <KeybindingSetup>           全局键位 Provider
    <GlobalKeybindingHandlers>
    <CommandKeybindingHandlers>
    <CancelRequestHandler>
    <Box flexDirection="column" height="100%">
      <StatusBar />           顶部状态条
      <VirtualMessageList />  中间消息流
      <PromptInput />         底部输入框
      <PromptInputFooter />   输入框底部 hint
      <DialogLayer>           模态对话框层
        <PermissionRequest /> 权限询问
        <PromptDialog />      普通提问
        <ElicitationDialog /> MCP elicit
        <CostThresholdDialog /> 费用阈值
        <IdleReturnDialog />  闲置返回
        <SkillImprovementSurvey /> 技能调研
        ...
      </DialogLayer>
    </Box>
  </KeybindingSetup>
</REPL>

⚠️ Dialog Layer 的实现技巧:在 TUI 里,没有真正的 z-index。"模态"靠"渲染顺序靠后 + 全屏 Box 覆盖"实现。

2.8 性能优化点

REPL 处理长会话(几千条消息)+ 流式响应(每秒数十个 token 更新),性能至关重要:

2.8.1 虚拟化

  • VirtualMessageList 维护一个滑动窗口,只渲染可见的 ~50 条
  • 滚动时用 requestAnimationFrame 节流

2.8.2 Deferred updates

import { useDeferredValue, useDeferredHookMessages } from 'react';
- useDeferredValue —— 流式响应时低优先级更新(React 18 Concurrent) - useDeferredHookMessages —— 自研的 hook 消息延迟更新

2.8.3 后台 housekeeping

import { startBackgroundHousekeeping } from '../utils/backgroundHousekeeping.js';
不阻塞主线程的清理任务(清理文件缓存、压缩 session 等)。

2.8.4 FPS 监控

import { useFpsMetrics } from '../context/fpsMetrics.js';
监听渲染帧率,< 30 fps 时降级渲染(前端 TUI 的卡顿监控)。

2.9 关键洞察

2.9.1 REPL 是"功能清单"而非"组件"

不要把 REPL 当成"一个组件"读。它是 85 个 hooks + 50+ 子组件的编排器。理解这个本质后,5000 行就没那么可怕了——大部分是 import 和 hook 调用。

2.9.2 Inbox Polling 与多 agent

import { useInboxPoller } from '../hooks/useInboxPoller.js';
import { useSwarmInitialization } from '../hooks/useSwarmInitialization.js';
import { useTeammateViewAutoExit } from '../hooks/useTeammateViewAutoExit.js';
Claude Code 支持多 agent 协作(teammate / swarm 模式),REPL 通过 polling 监听其他 agent 的消息。

2.9.3 Voice / SSH / IDE 集成

REPL 同时支持: - 本地 TTY 交互 - Voice mode(语音输入) - SSH 远程会话 - IDE bridge(VSCode/JetBrains 集成) - Remote session(云端会话)

每种模式对应一个 use*Session hook,REPL 内部按条件启用。

2.9.4 Ant-only / DCE 分支

REPL.tsx 顶部有大量:

const AntModelSwitchCallout = "external" === 'ant' ? require(...) : null;
const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL') ? require(...) : null;
这些分支外部构建是 null,可以直接跳过

2.10 阅读清单(按优先级)

  1. src/screens/REPL.tsx:1-120(imports)—— 看依赖图
  2. src/screens/REPL.tsx:572-650REPL 函数签名 + props 类型)—— 看 API 边界
  3. src/components/PromptInput/PromptInput.tsx:1-80(imports + props)—— 输入组件全貌
  4. src/components/VirtualMessageList.tsx:1-60(imports + props)—— 虚拟列表 API
  5. 🔍 src/screens/REPL.tsx:4200-4500(JSX return)—— 渲染树结构
  6. 🔍 src/components/PromptInput/PromptInput.tsx:2200-2338(事件处理)—— 提交流程
  7. 📌 src/keybindings/KeybindingProviderSetup.tsx(通读)—— 键位系统
  8. 📌 src/hooks/useLogMessages.ts + useAfterFirstRender.ts(通读)—— 副作用模板

2.11 练习任务

  1. 列出 REPL.tsx 的所有 import 类别(渲染/输入/状态/键位/工具/服务/hooks),归类写下来
  2. 画一张"用户键入到消息渲染"的数据流图,标注每个节点对应的文件
  3. 找到 useInput 的所有用法grep -rn "useInput(" src/),理解键位系统全貌
  4. 对比 Web React:如果让你把 <VirtualMessageList> 改成 Web 版的虚拟列表(用 react-window),你会在哪里改?怎么改?

2.12 下一步

进入 阶段 3:状态管理 —— REPL 引用的 state/* 是个 60 行的极简 store,和 Zustand 几乎一样。