REPL 界面
How screens/REPL.tsx — 超过 5,000 行 — 将每条消息、工具调用、权限对话框和击键编排成一个连贯的对话循环。
screens/REPL.tsx 是整个代码库中最大且最重要的文件。 这是 React 组件 is Claude Code 会话。 屏幕上可见的所有内容——对话历史记录、旋转器、权限提示、输入框——都是在这里精心安排的。
该文件由大约 5,000 行编译而成,并导出一个函数: REPL(props)。 尽管文件很大,但该文件具有清晰的内部结构,可以分层读取:
道具和环境警卫
类型定义为 Props、功能标志备忘录和安装时间 useEffect logging.
国家声明
Every useState and useRef:消息、中止控制器、加载标志、对话队列、流文本、输入值、屏幕模式。
核心回调
setMessages, onCancel, getToolUseContext, onQueryEvent, onQueryImpl, onQuery, onSubmit.
效果和辅助系统
会话恢复、队列处理器、通知挂钩、键盘处理程序、空闲检测、队友收件箱。
转录模式
The screen === 'transcript' 提前返回、虚拟滚动布局、搜索栏、转储模式。
主要渲染
主 JSX 树: FullscreenLayout, Messages、微调器、对话框、 PromptInput,键绑定处理程序。
每个用户交互都经过一系列三个功能。 了解链条是整个文件的万能钥匙。
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 */]);
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);
}
REPL.tsx 中更微妙的设计决策之一是它如何跟踪“Claude 目前在工作吗?” 三个独立的来源都可以使微调器出现:
| Source | Mechanism | 当它着火时 |
|---|---|---|
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).
当多个事物同时需要用户注意时(权限提示、空闲返回提示、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 自然地落入取消处理程序。
对话存储在 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);
}, []);
三种相关机制使消息数组保持一致:
- 短暂的进步替代 — Sleep 和 Bash 每秒都会发出进度滴答声。 REPL.tsx 不是追加(这会使数组增加到 13,000 多个条目),而是就地替换了同一工具使用 ID 的前一个刻度。
- 紧凑的边界处理 - 什么时候
query()发出紧凑边界消息,消息数组仅替换为压缩后消息。 在全屏模式下,预压缩消息将保留以供回滚,但上限为一个压缩间隔。 - 延迟渲染 —
useDeferredValue(messages)producesdeferredMessages,其中Messages组件以转换优先级渲染。 这可以使输入框在流式传输期间保持响应。 当流文本可见时,延迟路径将被绕过(因此最终消息出现在流文本清除的同一帧中)。
工具和斜线命令可以通过调用呈现自定义 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 命令呈现在 模态槽 (绝对定位,底部锚定)而不是在可滚动区域中内联。 这可以防止对话框在新消息到达时抖动。
REPL 有两个屏幕,通过以下方式切换 screen: 'prompt' | 'transcript' state:
转录模式早期返回(第 4392 课 行左右)的存在是出于关键的性能原因:没有虚拟滚动,将所有消息呈现在 ScrollBox 将为长会话分配约 250 MB。 转录模式使 VirtualMessageList 仅呈现可见行的路径。
转录模式还可以提供较少风格的搜索体验 / 打开搜索栏, n/N 用于导航, v 打开于 $VISUAL/$EDITOR, 和 [ 转储到终端回滚。
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('')
restoreSessionMetadata 只设置日志中真实的字段。 如果没有清除,没有代理名称的恢复会话将继承 previous 会话的缓存名称 - 并将该过时的名称写入第一条消息的错误记录中。
当用户按 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 对于退出路径 - 但自动恢复仍必须运行。
最终的 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、流模式)读取状态
onSubmit30+ 稳定setMessages每轮调用,防止级联闭包捕获和内存泄漏。 - 对话系统是一个纯函数:
getFocusedInputDialog()从确定性优先级列表中准确返回一个获胜者。 所有渲染都以该值为条件——没有临时的布尔汤。 - 根据设计,中断自动恢复在生成保护之外运行:
forceEnd()在finally块运行之前碰撞生成,因此必须开启自动恢复signal.reasonand!queryGuard.isActiveinstead. - 全屏模式和回滚模式产生结构相同的输出 - 区别在于是否
<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。
知识检查
messages via messagesRef.current 在回调内部而不是添加 messages 到 useCallback 部门阵列?QueryGuard的世代计数器?ScrollKeybindingHandler 之前渲染 CancelRequestHandler 在 JSX 树中?queryGuard.end(thisGeneration) 堵塞。 为什么?isLoading return true 即使没有本地 onQuery 正在运行?