Claude Code 源码分析第 37 课 · 第 08
第 37 课

REPL 界面

How screens/REPL.tsx — 超过 5,000 行 — 将每条消息、工具调用、权限对话框和击键编排成一个连贯的对话循环。

01 REPL.tsx 实际上做了什么

screens/REPL.tsx 是整个代码库中最大且最重要的文件。 这是 React 组件 is Claude Code 会话。 屏幕上可见的所有内容——对话历史记录、旋转器、权限提示、输入框——都是在这里精心安排的。

该文件由大约 5,000 行编译而成,并导出一个函数: REPL(props)。 尽管文件很大,但该文件具有清晰的内部结构,可以分层读取:

第 1 层 — 行 ~526–700

道具和环境警卫

类型定义为 Props、功能标志备忘录和安装时间 useEffect logging.

第 2 层 — 行 ~700–1200

国家声明

Every useState and useRef:消息、中止控制器、加载标志、对话队列、流文本、输入值、屏幕模式。

第 3 层 — 行 ~1200–2700

核心回调

setMessages, onCancel, getToolUseContext, onQueryEvent, onQueryImpl, onQuery, onSubmit.

第 4 层 — 线路 ~2700–4100

效果和辅助系统

会话恢复、队列处理器、通知挂钩、键盘处理程序、空闲检测、队友收件箱。

第 5 层 — 行 ~4100–4490

转录模式

The screen === 'transcript' 提前返回、虚拟滚动布局、搜索栏、转储模式。

第 6 层 — 行 ~4490–5005

主要渲染

主 JSX 树: FullscreenLayout, Messages、微调器、对话框、 PromptInput,键绑定处理程序。

02 回合生命周期

每个用户交互都经过一系列三个功能。 了解链条是整个文件的万能钥匙。

flowchart TD A[User presses Enter\nonSubmit] --> B{slash command\nand immediate?} B -->|Yes| C[executeImmediateCommand\nsetToolJSX, skip queue] B -->|No| D[handlePromptSubmit\nin utils/] D --> E{already loading?} E -->|Yes| F[enqueue to\nmessageQueueManager] E -->|No| G[onQuery] G --> H[queryGuard.tryStart\nconcurrency guard] H -->|null = collision| I[enqueue and return] H -->|generation token| J[setMessages + newMessages] J --> K[onQueryImpl] K --> L[getToolUseContext\nbuild full context] L --> M[getSystemPrompt\nparallel with getUserContext] M --> N[query iterator\nfor await event of query] N --> O[onQueryEvent\nhandleMessageFromStream] O --> P{compact boundary?} P -->|Yes| Q[setMessages boundary\nbump conversationId] P -->|No| R[append or replace\nmessage] N --> S[resetLoadingState\nonTurnComplete] S --> T[queryGuard.end\ncheck auto-restore]

onSubmit — 入口点

onSubmit 击键变成了 API 调用。 函数签名揭示了它的职责:

const onSubmit = useCallback(async (
  input: string,
  helpers: PromptInputHelpers,
  speculationAccept?: ActiveSpeculationState,
  options?: { fromKeybinding?: boolean }
) => {
  repinScroll();          // snap to bottom on any submit

  // Fast path: immediate local-jsx commands run NOW even while Claude is busy
  if (shouldTreatAsImmediate) {
    void executeImmediateCommand();
    return;
  }

  // Idle-return gate: if user has been gone 75+ min, show dialog first
  // Add to shell/history, restore stashed prompt, clear input field
  // Route remote mode through WebSocket, not local query

  await awaitPendingHooks();   // block until SessionStart hooks resolve
  await handlePromptSubmit(...); // shell mode, command routing, onQuery
}, [/* ~25 deps */]);
为什么要使用大型 dep 数组?
源码中的评论很明确: messages 是有意通过读取 messagesRef.current 内部回调(不通过闭包)以保持 onSubmit 稳定在〜30 setMessages 每回合呼叫数。 如果没有这种纪律,每个流增量都会重新创建 onSubmit,将过时的 REPL 渲染范围固定在内存中。 堆分析发现,在修复此问题之前,每回合约有 9 个泄漏 REPL 范围。

onQuery — 并发防护

onQuery wraps onQueryImpl 使用关键状态机: QueryGuard。 与简单的布尔标志不同,守卫使用了一个陈旧的生成计数器 finally 取消查询的块不会错误地更新状态。

const thisGeneration = queryGuard.tryStart();
if (thisGeneration === null) {
  // Already running — extract user text and enqueue it
  newMessages.filter(isUserMessage).forEach(msg => enqueue({ value, mode: 'prompt' }));
  return;
}
try {
  await onQueryImpl(messagesRef.current, newMessages, abortController, ...);
} finally {
  if (queryGuard.end(thisGeneration)) {
    // Only the latest generation cleans up
    resetLoadingState();
    await mrOnTurnComplete(messagesRef.current, aborted);
  }
  // Auto-restore runs OUTSIDE the generation check
  // (forceEnd bumps generation; end() returns false for Esc path)
  if (abortController.signal.reason === 'user-cancel' && !queryGuard.isActive ...) {
    restoreMessageSync(lastUserMsg);
  }
}

onQueryImpl — API 调用

onQueryImpl 实际工作:构建系统提示符,调用 query(),并通过流式传输响应 onQueryEvent。 关键步骤:

// 1. Haiku title extraction (one-shot, first real user message only)
if (!titleDisabled && !haikuTitleAttemptedRef.current) {
  void generateSessionTitle(text, signal).then(t => setHaikuTitle(t));
}

// 2. Write skill-scoped allowedTools to store BEFORE the API call
store.setState(prev => ({ ...prev, toolPermissionContext: { ...prev.toolPermissionContext,
  alwaysAllowRules: { ...prev.toolPermissionContext.alwaysAllowRules, command: additionalAllowedTools }
}}));

// 3. Build full context — all reads from store.getState() not render closure
const toolUseContext = getToolUseContext(messages, newMessages, abortController, model);

// 4. Parallel async: system prompt + user context + killswitch checks
const [,, defaultSystemPrompt, userContext, systemContext] = await Promise.all([
  checkAndDisableBypassPermissionsIfNeeded(...),
  getSystemPrompt(freshTools, model, workingDirs, mcpClients),
  getUserContext(),
  getSystemContext()
]);

// 5. Stream the query
for await (const event of query({ messages, systemPrompt, canUseTool, toolUseContext, ... })) {
  onQueryEvent(event);
}
03 加载状态:三个事实来源

REPL.tsx 中更微妙的设计决策之一是它如何跟踪“Claude 目前在工作吗?” 三个独立的来源都可以使微调器出现:

SourceMechanism当它着火时
isQueryActive useSyncExternalStore(queryGuard.subscribe, queryGuard.getSnapshot) Local onQuery 正在运行
isExternalLoading useState + setIsExternalLoading 远程会话/SSH/前台后台任务
hasRunningTeammates useMemo over tasks AppState Swarm 工作代理仍在执行
const isLoading = isQueryActive || isExternalLoading;
const showSpinner = (!toolJSX || toolJSX.showSpinner === true)
  && toolUseConfirmQueue.length === 0
  && promptQueue.length === 0
  && (isLoading || userInputOnProcessing || hasRunningTeammates || getCommandQueueLength() > 0)
  && !pendingWorkerRequest
  && !onlySleepToolActive
  && (!visibleStreamingText || isBriefOnly);
时序参考模式
旋转器中经过的时间计算如下 loadingStartTimeRef,而不是状态 - 因此动画帧可以读取它而不触发重新渲染。 参考在第一次渲染时内联重置 isQueryActive 变成真的,而不是在一个 useEffect。 评论解释了原因:有一场比赛,在第一次旋转器渲染后触发了效果,导致它显示“56 年过去了”(Date.now() - 0).
04 对话优先队列

当多个事物同时需要用户注意时(权限提示、空闲返回提示、IDE 入门对话框),REPL.tsx 通过单个解决冲突 getFocusedInputDialog() 返回所有可能的对话框类型的字符串并集的函数:

function getFocusedInputDialog():
  'message-selector' | 'sandbox-permission' | 'tool-permission' |
  'prompt' | 'worker-sandbox-permission' | 'elicitation' |
  'cost' | 'idle-return' | 'ide-onboarding' | ... | undefined

  // Priority order (highest to lowest):
  if (isMessageSelectorVisible) return 'message-selector';    // always
  if (isPromptInputActive) return undefined;                   // suppress while typing
  if (sandboxPermissionRequestQueue[0]) return 'sandbox-permission';
  // ... permission/interactive dialogs ...
  // ... onboarding dialogs ...
  // ... callouts (effort, remote, LSP rec) ...
  return undefined;

The isPromptInputActive 防护特别值得注意:中断对话框是 suppressed 当用户打字时。 1.5 秒去抖 (PROMPT_SUPPRESSION_MS = 1500) 在最后一次击键后重置标志。 这可以防止用户在说话时意外取消权限。

排序约束
ScrollKeybindingHandler 必须渲染 before CancelRequestHandler 在 JSX 树中。 评论解释道: ctrl+c 文本选择应该复制,而不是取消活动任务。 滚动处理程序的 useInput 仅当存在选择时才停止传播 - 没有选择, ctrl+c 自然地落入取消处理程序。
05 消息数组:真理之源

对话存储在 messages: MessageType[] 状态数组,但它是 not 用简单的方式管理 useState。 使用的包装器模式与 Zustand 相同:ref 保存实时值,React 状态是渲染投影:

const [messages, rawSetMessages] = useState<MessageType[]>(initialMessages ?? []);
const messagesRef = useRef(messages);

const setMessages = useCallback((action) => {
  const prev = messagesRef.current;
  const next = typeof action === 'function' ? action(messagesRef.current) : action;
  messagesRef.current = next;             // sync update — no await needed
  if (next.length > prev.length && userMessagePendingRef.current) {
    // Track whether the submitted user message has landed yet
    // to control the placeholder text visibility
  }
  rawSetMessages(next);
}, []);

三种相关机制使消息数组保持一致:

  1. 短暂的进步替代 — Sleep 和 Bash 每秒都会发出进度滴答声。 REPL.tsx 不是追加(这会使数组增加到 13,000 多个条目),而是就地替换了同一工具使用 ID 的前一个刻度。
  2. 紧凑的边界处理 - 什么时候 query() 发出紧凑边界消息,消息数组仅替换为压缩后消息。 在全屏模式下,预压缩消息将保留以供回滚,但上限为一个压缩间隔。
  3. 延迟渲染useDeferredValue(messages) produces deferredMessages,其中 Messages 组件以转换优先级渲染。 这可以使输入框在流式传输期间保持响应。 当流文本可见时,延迟路径将被绕过(因此最终消息出现在流文本清除的同一帧中)。
06 toolJSX 覆盖系统

工具和斜线命令可以通过调用呈现自定义 UI setToolJSX()。 REPL 跟踪两个独立的覆盖槽:

const [toolJSX, setToolJSXInternal] = useState<{
  jsx: React.ReactNode | null;
  shouldHidePromptInput: boolean;
  shouldContinueAnimation?: true;
  showSpinner?: boolean;
  isLocalJSXCommand?: boolean;
  isImmediate?: boolean;
} | null>(null);

const localJSXCommandRef = useRef(...); // preserves /btw and similar while Claude streams

The setToolJSX 包装器强制执行一个重要的不变量: 本地 JSX 命令无法被工具更新覆盖。 如果用户运行 /btw (在 Claude 继续处理时显示覆盖),后续工具更新将被默默忽略,直到用户明确使用 clearLocalJSX: true.

在全屏模式下,本地 JSX 命令呈现在 模态槽 (绝对定位,底部锚定)而不是在可滚动区域中内联。 这可以防止对话框在新消息到达时抖动。

07 两种渲染路径:提示与转录

REPL 有两个屏幕,通过以下方式切换 screen: 'prompt' | 'transcript' state:

flowchart LR A[screen state] --> B{value} B -- "'transcript'" --> C[Early return\nTranscript render] B -- "'prompt'" --> D[Main render\nmainReturn] C --> E{fullscreen\nenabled?} E -- Yes --> F[AlternateScreen\n+ FullscreenLayout\n+ ScrollBox] E -- No --> G[Inline dump\n30-msg cap\nCtrl+E expand] D --> H{fullscreen\nenabled?} H -- Yes --> I[AlternateScreen\n+ FullscreenLayout\nwith scrollRef] H -- No --> J[mainReturn direct]

转录模式早期返回(第 4392 课 行左右)的存在是出于关键的性能原因:没有虚拟滚动,将所有消息呈现在 ScrollBox 将为长会话分配约 250 MB。 转录模式使 VirtualMessageList 仅呈现可见行的路径。

转录模式还可以提供较少风格的搜索体验 / 打开搜索栏, n/N 用于导航, v 打开于 $VISUAL/$EDITOR, 和 [ 转储到终端回滚。

08 会话恢复流程

The resume() 回调处理 /resume 命令。 它是文件中最复杂的操作之一,协调一长串状态重置:

// 1. Deserialize messages (clean up unresolved tool uses)
// 2. Fire SessionEnd hooks for the current session
// 3. Fire SessionStart hooks for the resumed session
// 4. Copy or reuse the plan slug (fork vs resume)
// 5. Restore file history snapshots
// 6. Restore agent definition (name, color, type)
// 7. Restore standalone agent context
// 8. Save current session costs before switchSession()
// 9. Reset cost state, then restore target session costs
// 10. Atomically switch sessionId + project dir
// 11. Rename asciicast recording to match new session ID
// 12. Clear then restore session metadata (ordering matters)
// 13. Exit current worktree, enter resumed session's worktree
// 14. Reconstruct contentReplacementState for the new session
// 15. setMessages → setToolJSX(null) → setInputValue('')
为什么在恢复SessionMetadata之前先清除SessionMetadata?
restoreSessionMetadata 只设置日志中真实的字段。 如果没有清除,没有代理名称的恢复会话将继承 previous 会话的缓存名称 - 并将该过时的名称写入第一条消息的错误记录中。
09 中断时自动恢复

当用户按 Escape 打断 Claude 并且查询没有产生有意义的响应时,REPL.tsx 会自动倒回对话并恢复他们的输入。 此功能有几个保护措施:

// Inside the onQuery finally block:
if (
  abortController.signal.reason === 'user-cancel'  // Esc, not background/interrupt
  && !queryGuard.isActive                             // no newer query racing in
  && inputValueRef.current === ''                   // user hasn't typed anything
  && getCommandQueueLength() === 0                   // no queued commands (don't undo B while A was loading)
  && !store.getState().viewingAgentTaskId             // not viewing a teammate's transcript
) {
  const lastUserMsg = msgs.findLast(selectableUserMessagesFilter);
  if (lastUserMsg && messagesAfterAreOnlySynthetic(msgs, idx)) {
    removeLastFromHistory();  // undo the history entry too
    restoreMessageSync(lastUserMsg);
  }
}

这运行 outside the queryGuard.end() 检查因为 onCancel calls queryGuard.forceEnd(),这会增加生成计数器。 end(thisGeneration) returns false 对于退出路径 - 但自动恢复仍必须运行。

10 主渲染树剖析

最终的 JSX 树组装了所有部分。 简化结构:

<AlternateScreen mouseTracking>
  <KeybindingSetup>                        // provides keybinding context
    <AnimatedTerminalTitle />               // 960ms tick, isolated leaf
    <GlobalKeybindingHandlers />            // ctrl+o transcript toggle, etc.
    <ScrollKeybindingHandler />             // PgUp/PgDn/g/G — BEFORE CancelRequest
    <CancelRequestHandler />               // Esc / ctrl+c
    <MCPConnectionManager>                  // manages MCP server lifecycle
      <FullscreenLayout
        scrollRef={scrollRef}              // shared with ScrollKeybindingHandler
        overlay={toolPermissionOverlay}    // PermissionRequest floats above messages
        modal={centeredModal}              // local-jsx commands in fullscreen
        scrollable={<>
          <TeammateViewHeader />
          <Messages messages={displayedMessages} />
          {placeholderText && <UserTextMessage param={placeholderText} />}
          {toolJSX && <Box>{toolJSX.jsx}</Box>}
          {showSpinner && <SpinnerWithVerb />}
          <PromptInputQueuedCommands />
        </>}
        bottom={<Box>
          {permissionStickyFooter}
          {focusedInputDialog === 'sandbox-permission' && <SandboxPermissionRequest />}
          {focusedInputDialog === 'tool-permission' && <PermissionRequest />}
          // ... other dialogs keyed to focusedInputDialog ...
          <FeedbackSurvey />
          <PromptInput onSubmit={onSubmit} />
          <SessionBackgroundHint />
          {cursor && <MessageActionsBar />}
          {focusedInputDialog === 'message-selector' && <MessageSelector />}
        </Box>}
      />
    </MCPConnectionManager>
  </KeybindingSetup>
</AlternateScreen>

要点

  • REPL.tsx 有 5,000 行,因为它具有真正的复杂性 — 它在一个组件中处理并发、权限队列、远程会话、群工作人员、两种渲染模式、会话恢复和键盘导航。
  • The QueryGuard 状态机取代了一个简单的布尔值,并防止同步取消和 React 的异步批处理之间的不同步。 世代数意味着陈旧 finally 块不会破坏状态。
  • 通过回调内部的引用(消息、inputValue、流模式)读取状态 onSubmit 30+ 稳定 setMessages 每轮调用,防止级联闭包捕获和内存泄漏。
  • 对话系统是一个纯函数: getFocusedInputDialog() 从确定性优先级列表中准确返回一个获胜者。 所有渲染都以该值为条件——没有临时的布尔汤。
  • 根据设计,中断自动恢复在生成保护之外运行: forceEnd() 在finally块运行之前碰撞生成,因此必须开启自动恢复 signal.reason and !queryGuard.isActive instead.
  • 全屏模式和回滚模式产生结构相同的输出 - 区别在于是否 <AlternateScreen> 包裹树以及是否虚拟滚动 ScrollBox 已安装。
深入探讨:setMessages 引用模式

在回调中读取状态的标准 React 模式是将状态添加到 useCallback 部门数组。 REPL.tsx 故意打破此规则 messages:

// messages is read via messagesRef.current inside the callback to
// keep onSubmit stable across message updates (see L2384/L2400/L2662).
// Without this, each setMessages call (~30× per turn) recreates
// onSubmit, pinning the REPL render scope (1776B) + that render's
// messages array in downstream closures (PromptInput, handleAutoRunIssue).
// Heap analysis showed ~9 REPL scopes and ~15 messages array versions
// accumulating after #20174/#20175, all traced to this dep.

权衡是,参考必须在每次渲染上保持同步——这是 setMessages 包装器同步执行。 任何需要异步回调中最新消息的代码都会读取 messagesRef.current,不是封闭的 messages。 此模式在整个文件中重复出现: inputValueRef, streamModeRef, abortControllerRef, 和 onSubmitRef 出于同样的原因,它们都保持同步。

深入探讨:AnimatedTerminalTitle 隔离

当 Claude 工作时,终端选项卡标题会以旋转字形 (⠂/⠐) 进行动画显示,每 960 毫秒循环一次。 一个幼稚的实现会将其置于 REPL 状态 - 但需要 960 毫秒 setInterval 重新渲染 REPL 会在每次勾选时拖动 PromptInput、Messages 和所有其他子项。

解决方案是将动画提取到一个单独的叶组件中,该组件返回 null (纯粹的副作用通过 useTerminalTitle)。 只有这个微小的组件会在每次更新时重新渲染。

function AnimatedTerminalTitle({ isAnimating, title, disabled, noPrefix }) {
  const [frame, setFrame] = useState(0);
  useEffect(() => {
    if (!isAnimating) return;
    const interval = setInterval(() => setFrame(f => (f + 1) % frames.length), 960);
    return () => clearInterval(interval);
  }, [isAnimating]);
  useTerminalTitle(disabled ? null : `${prefix} ${title}`);
  return null;  // zero render cost
}
深入探讨:立即与非立即本地 JSX 命令

渲染自定义 UI 的斜线命令(type: 'local-jsx')分为两个放置类别:

Category渲染地点Why
立即(/btw, /sandbox) bottom 槽,位于 ScrollBox 外部 在主循环流动时保持安装状态。 如果放置在 ScrollBox 内,附加的新消息会晃动对话框位置。
非立即数(/diff, /status, /theme) scrollable 插槽,位于 ScrollBox 内 当它们运行时,主循环暂停,因此不会出现抖动。 它们的高内容(DiffDetailView 高达 400 行)需要外部 ScrollBox 来实现可滚动性。
全屏模式(/config, /model) modal 槽,绝对定位 在全屏模式下,所有 local-jsx 命令都使用居中的模式槽来实现一致的视觉处理。
深入探讨:看不见的消息分隔线

当用户在 Claude 响应时向上滚动时,新消息会累积在视口下方。 REPL.tsx 跟踪有多少未见过的消息并显示“跳转到新”药丸。

关键洞察: dividerIndex 仅更改 twice 每个滚动会话(当用户滚动离开时一次,当他们重新固定时一次)。 这意味着 useUnseenDivider 即使有数十条消息流入,也很少会触发重新渲染。药丸可见性和粘性提示状态在内部管理 FullscreenLayout,它直接订阅 ScrollBox — 因此每帧滚动永远不会重新渲染 REPL。

知识检查

Q1. 为什么 REPL.tsx 读取 messages via messagesRef.current 在回调内部而不是添加 messagesuseCallback 部门阵列?
Q2. 目的是什么 QueryGuard的世代计数器?
Q3. 为什么必须 ScrollKeybindingHandler 之前渲染 CancelRequestHandler 在 JSX 树中?
Q4. 中断运行时自动恢复 outside the queryGuard.end(thisGeneration) 堵塞。 为什么?
Q5. 什么时候 isLoading return true 即使没有本地 onQuery 正在运行?