本章总览
REPL.tsx(v2.1.88 反编译,约 5000 行)是 Claude Code 交互式会话的「导演」:从第 572 行起的 export function REPL 汇聚 query 循环、权限队列、消息列表、底部输入与全屏布局。本章带源码走读,要求你能从用户按 Enter 追到 canUseTool、Messages 与 PermissionRequest overlay。
学完本章你应该能
- 说明 REPL Props 中 remote/ssh/taskList 等模式开关的含义
- 解释 toolUseConfirmQueue 与 focusedInputDialog 的焦点模型
- 描述 prompt 屏与 transcript 屏(Screen 类型)的切换
- 定位 canUseTool 注册点与 PermissionRequest 渲染条件
- 理解 displayedMessages 在 agent 视图与 deferred 路径下的差异
核心概念(先读懂这些)
REPL 是状态机而非纯布局
REPL 维护大量 useState:screen、toolUseConfirmQueue、promptQueue、sandbox 队列、loading、cursor 等。sessionStatus 三元组 idle/busy/waiting 驱动终端标题动画与 macOS 防休眠。waiting 时 tab 状态会写入 PID 文件供 claude ps 使用。这不是简单的「聊天窗口」,而是把引擎异步事件映射成可消费的 UI 状态。
FullscreenLayout 的 slot 模型
全屏模式下 scrollable 区域放 Messages + spinner,bottom 放 PromptInput,overlay 放 PermissionRequest,modal 放 /config 等 local-jsx 命令。permissionStickyFooter 由 ExitPlanMode 等权限组件注册,保证长计划滚动时选项仍可见。理解 slot 后,新增全局浮层时知道应挂 overlay 还是 modal。
建议学习步骤
- 阅读 REPL 函数签名与 Props(源码块 A)
- 跟踪 toolUseConfirmQueue 声明与 isWaitingForApproval(源码块 B)
- 阅读 canUseTool = useCanUseTool(...) 注册(源码块 C)
- 对照 toolPermissionOverlay 与 Messages 传参(源码块 D、E)
- 在源码树展开 screens/REPL.tsx 核对 import 清单
常见误区
注意
React Compiler 的 _c / $ 缓存语法可忽略,聚焦业务状态名
注意
viewedAgentTask 时 displayedMessages 来自 task,勿与 leader messages 混淆
注意
deferredMessages 仅在非 streaming 且 loading 时启用,影响占位符显示时机
在架构中的位置
Claude Code 启动后,main 路由最终渲染 <REPL />。数据流概览:
用户输入 (PromptInput.onSubmit)
→ processUserInput / query 循环
→ API 流式返回 assistant blocks
→ setMessages 更新 transcript
→ Messages → MessageRow → messages/* 叶子组件
并行:tool_use 前 await canUseTool()
→ ask 时 push ToolUseConfirm 到 toolUseConfirmQueue
→ focusedInputDialog === 'tool-permission'
→ overlay 渲染 PermissionRequest
REPL 同时 import 了 VirtualMessageList、PermissionRequest、PromptInput、数十个 hooks。改「会话行为」优先查 REPL;改「单条消息样式」查 messages/;改「Allow 按钮文案」查 permissions/。
REPL 入口与 Props
下列源码为 export function REPL 起始段(约 572 行)。注意 thinkingConfig 为必填项,disabled 会隐藏输入并禁用 message selector,taskListId 启用任务列表自动处理模式。
阅读要点:
remoteSessionConfig/directConnectConfig/sshSession三套远程形态互斥使用场景不同- mount 时
useEffect打调试日志,便于排查 REPL 意外卸载 useAppState批量订阅 MCP、plugins、tasks、elicitation 等全局切片
源码引用: src/screens/REPL.tsx · 第 571–640 行(共 7050 行)
571| MessageActionsKeybindings,
572| MessageActionsBar,
573| type MessageActionsState,
574| type MessageActionsNav,
575| type MessageActionCaps,
576| } from '../components/messageActions.js'
577| import { setClipboard } from '../ink/termio/osc.js'
578| import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'
579| import {
580| createAttachmentMessage,
581| getQueuedCommandAttachments,
582| } from '../utils/attachments.js'
583|
584| // Stable empty array for hooks that accept MCPServerConnection[] — avoids
585| // creating a new [] literal on every render in remote mode, which would
586| // cause useEffect dependency changes and infinite re-render loops.
587| const EMPTY_MCP_CLIENTS: MCPServerConnection[] = []
588|
589| // Stable stub for useAssistantHistory's non-KAIROS branch — avoids a new
590| // function identity each render, which would break composedOnScroll's memo.
591| const HISTORY_STUB = { maybeLoadOlder: (_: ScrollBoxHandle) => {} }
592| // Window after a user-initiated scroll during which type-into-empty does NOT
593| // repin to bottom. Josh Rosen's workflow: Claude emits long output → scroll
594| // up to read the start → start typing → before this fix, snapped to bottom.
595| // https://anthropic.slack.com/archives/C07VBSHV7EV/p1773545449871739
596| const RECENT_SCROLL_REPIN_WINDOW_MS = 3000
597|
598| // Use LRU cache to prevent unbounded memory growth
599| // 100 files should be sufficient for most coding sessions while preventing
600| // memory issues when working across many files in large projects
601|
602| function median(values: number[]): number {
603| const sorted = [...values].sort((a, b) => a - b)
604| const mid = Math.floor(sorted.length / 2)
605| return sorted.length % 2 === 0
606| ? Math.round((sorted[mid - 1]! + sorted[mid]!) / 2)
607| : sorted[mid]!
608| }
609|
610| /**
611| * Small component to display transcript mode footer with dynamic keybinding.
612| * Must be rendered inside KeybindingSetup to access keybinding context.
613| */
614| function TranscriptModeFooter({
615| showAllInTranscript,
616| virtualScroll,
617| searchBadge,
618| suppressShowAll = false,
619| status,
620| }: {
621| showAllInTranscript: boolean
622| virtualScroll: boolean
623| /** Minimap while navigating a closed-bar search. Shows n/N hints +
624| * right-aligned count instead of scroll hints. */
625| searchBadge?: { current: number; count: number }
626| /** Hide the ctrl+e hint. The [ dump path shares this footer with
627| * env-opted dump (CLAUDE_CODE_NO_FLICKER=0 / DISABLE_VIRTUAL_SCROLL=1),
628| * but ctrl+e only works in the env case — useGlobalKeybindings.tsx
629| * gates on !virtualScrollActive which is env-derived, doesn't know
630| * [ happened. */
631| suppressShowAll?: boolean
632| /** Transient status (v-for-editor progress). Notifications render inside
633| * PromptInput which isn't mounted in transcript — addNotification queues
634| * but nothing draws it. */
635| status?: string
636| }): React.ReactNode {
637| const toggleShortcut = useShortcutDisplay(
638| 'app:toggleTranscript',
639| 'Global',
640| 'ctrl+o',
Screen 模式与 AppState 订阅
REPL 用 useState<Screen>('prompt' | 'transcript') 切换主视图。transcript 模式用于全文导出、搜索跳转(配合 VirtualMessageList 的 jumpRef)。同一段还展示 localCommands 热重载:skill 文件变更时 useSkillsChange 触发 setLocalCommands。
localTools = useMemo(() => getTools(toolPermissionContext), ...) 说明工具列表随权限上下文变化——与 PromptInput 展示的 permission mode 指示器联动。
源码引用: src/screens/REPL.tsx · 第 680–710 行(共 7050 行)
680| // Engine-counted — close enough for a rough location hint. May
681| // drift from render-count for ghost/phantom messages.
682| <>
683| <Box flexGrow={1} />
684| <Text dimColor>
685| {searchBadge.current}/{searchBadge.count}
686| {' '}
687| </Text>
688| </>
689| ) : null}
690| </Box>
691| )
692| }
693|
694| /** less-style / bar. 1-row, same border-top styling as TranscriptModeFooter
695| * so swapping them in the bottom slot doesn't shift ScrollBox height.
696| * useSearchInput handles readline editing; we report query changes and
697| * render the counter. Incremental — re-search + highlight per keystroke. */
698| function TranscriptSearchBar({
699| jumpRef,
700| count,
701| current,
702| onClose,
703| onCancel,
704| setHighlight,
705| initialQuery,
706| }: {
707| jumpRef: RefObject<JumpHandle | null>
708| count: number
709| current: number
710| /** Enter — commit. Query persists for n/N. */
源码引用: src/screens/REPL.tsx · 第 616–625 行(共 7050 行)
616| virtualScroll,
617| searchBadge,
618| suppressShowAll = false,
619| status,
620| }: {
621| showAllInTranscript: boolean
622| virtualScroll: boolean
623| /** Minimap while navigating a closed-bar search. Shows n/N hints +
624| * right-aligned count instead of scroll hints. */
625| searchBadge?: { current: number; count: number }
toolUseConfirmQueue 与等待态
权限确认队列是 REPL 最关键的 UI 状态之一:
toolUseConfirmQueue: ToolUseConfirm[]FIFO 处理多个待确认 tool_useisWaitingForApproval合并 promptQueue、worker、sandbox 等等待源sessionStatus变为 waiting 时,终端标题动画停止,并可能触发防休眠逻辑取消waitingFor字符串写入 session activity,供外部 ps 子命令展示
permissionStickyFooter 与队列并列:子权限组件通过 setStickyFooter 注册底部固定 JSX(长计划场景)。
源码引用: src/screens/REPL.tsx · 第 1101–1156 行(共 7050 行)
1101| )
1102| const editorRenderingRef = useRef(false)
1103| const { addNotification, removeNotification } = useNotifications()
1104|
1105| // eslint-disable-next-line prefer-const
1106| let trySuggestBgPRIntercept = SUGGEST_BG_PR_NOOP
1107|
1108| const mcpClients = useMergedClients(initialMcpClients, mcp.clients)
1109|
1110| // IDE integration
1111| const [ideSelection, setIDESelection] = useState<IDESelection | undefined>(
1112| undefined,
1113| )
1114| const [ideToInstallExtension, setIDEToInstallExtension] =
1115| useState<IdeType | null>(null)
1116| const [ideInstallationStatus, setIDEInstallationStatus] =
1117| useState<IDEExtensionInstallationStatus | null>(null)
1118| const [showIdeOnboarding, setShowIdeOnboarding] = useState(false)
1119| // Dead code elimination: model switch callout state (ant-only)
1120| const [showModelSwitchCallout, setShowModelSwitchCallout] = useState(() => {
1121| if ("external" === 'ant') {
1122| return shouldShowAntModelSwitch()
1123| }
1124| return false
1125| })
1126| const [showEffortCallout, setShowEffortCallout] = useState(() =>
1127| shouldShowEffortCallout(mainLoopModel),
1128| )
1129| const showRemoteCallout = useAppState(s => s.showRemoteCallout)
1130| const [showDesktopUpsellStartup, setShowDesktopUpsellStartup] = useState(() =>
1131| shouldShowDesktopUpsellStartup(),
1132| )
1133| // notifications
1134| useModelMigrationNotifications()
1135| useCanSwitchToExistingSubscription()
1136| useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus })
1137| useMcpConnectivityStatus({ mcpClients })
1138| useAutoModeUnavailableNotification()
1139| usePluginInstallationStatus()
1140| usePluginAutoupdateNotification()
1141| useSettingsErrors()
1142| useRateLimitWarningNotification(mainLoopModel)
1143| useFastModeNotification()
1144| useDeprecationWarningNotification(mainLoopModel)
1145| useNpmDeprecationNotification()
1146| useAntOrgWarningNotification()
1147| useInstallMessages()
1148| useChromeExtensionNotification()
1149| useOfficialMarketplaceNotification()
1150| useLspInitializationNotification()
1151| useTeammateLifecycleNotification()
1152| const {
1153| recommendation: lspRecommendation,
1154| handleResponse: handleLspResponse,
1155| } = useLspPluginRecommendation()
1156| const {
源码引用: src/screens/REPL.tsx · 第 1105–1116 行(共 7050 行)
1105| // eslint-disable-next-line prefer-const
1106| let trySuggestBgPRIntercept = SUGGEST_BG_PR_NOOP
1107|
1108| const mcpClients = useMergedClients(initialMcpClients, mcp.clients)
1109|
1110| // IDE integration
1111| const [ideSelection, setIDESelection] = useState<IDESelection | undefined>(
1112| undefined,
1113| )
1114| const [ideToInstallExtension, setIDEToInstallExtension] =
1115| useState<IdeType | null>(null)
1116| const [ideInstallationStatus, setIDEInstallationStatus] =
canUseTool 注册与 ToolUseContext
const canUseTool = useCanUseTool(setToolUseConfirmQueue, setToolPermissionContext) 把 Hook 返回值注入 query 引擎。
相邻的 getToolUseContext 回调从 store 读取最新 MCP/tools,避免闭包捕获陈旧工具列表——注释明确提到未来 headless 循环也需要此模式。
registerLeaderSetToolPermissionContext 表明 swarm leader 会把 setState 暴露给 in-process teammate,权限 UI 可能出现在 worker 徽章路径。
源码引用: src/screens/REPL.tsx · 第 2377–2420 行(共 7050 行)
2377| /* eslint-enable @typescript-eslint/no-require-imports */
2378| getAgentDefinitionsWithOverrides.cache.clear?.()
2379| const freshAgentDefs = await getAgentDefinitionsWithOverrides(
2380| getOriginalCwd(),
2381| )
2382|
2383| setAppState(prev => ({
2384| ...prev,
2385| agentDefinitions: {
2386| ...freshAgentDefs,
2387| allAgents: freshAgentDefs.allAgents,
2388| activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents),
2389| },
2390| }))
2391| messages.push(createSystemMessage(warning, 'warning'))
2392| }
2393| }
2394|
2395| // Fire SessionEnd hooks for the current session before starting the
2396| // resumed one, mirroring the /clear flow in conversation.ts.
2397| const sessionEndTimeoutMs = getSessionEndHookTimeoutMs()
2398| await executeSessionEndHooks('resume', {
2399| getAppState: () => store.getState(),
2400| setAppState,
2401| signal: AbortSignal.timeout(sessionEndTimeoutMs),
2402| timeoutMs: sessionEndTimeoutMs,
2403| })
2404|
2405| // Process session start hooks for resume
2406| const hookMessages = await processSessionStartHooks('resume', {
2407| sessionId,
2408| agentType: mainThreadAgentDefinition?.agentType,
2409| model: mainLoopModel,
2410| })
2411|
2412| // Append hook messages to the conversation
2413| messages.push(...hookMessages)
2414| // For forks, generate a new plan slug and copy the plan content so the
2415| // original and forked sessions don't clobber each other's plan files.
2416| // For regular resumes, reuse the original session's plan slug.
2417| if (entrypoint === 'fork') {
2418| void copyPlanForFork(log, asSessionId(sessionId))
2419| } else {
2420| void copyPlanForResume(log, asSessionId(sessionId))
displayedMessages 与占位符
渲染前列举三路消息来源:
- viewedAgentTask:只看 agent task 的 messages,绝不 fallthrough 到 leader(防 footgun)
- usesSyncMessages:流式文本或直接 sync,跳过 deferred
- deferredMessages:loading 时降低重绘频率
placeholderText 在 userInputOnProcessing 且消息数未超过 baseline 时显示 UserTextMessage 占位,让用户看到自己刚提交的内容;modal 打开时抑制,避免与 /config 等对话框重复。
源码引用: src/screens/REPL.tsx · 第 4505–4520 行(共 7050 行)
4505| // returns early without calling handlePromptSubmit).
4506| const submitsNow =
4507| !isLoading || speculationAccept || activeRemote.isRemoteMode
4508| if (stashedPrompt !== undefined && !isSlashCommand && submitsNow) {
4509| setInputValue(stashedPrompt.text)
4510| helpers.setCursorOffset(stashedPrompt.cursorOffset)
4511| setPastedContents(stashedPrompt.pastedContents)
4512| setStashedPrompt(undefined)
4513| } else if (submitsNow) {
4514| if (!options?.fromKeybinding) {
4515| // Clear input when not loading or accepting speculation.
4516| // Preserve input for keybinding-triggered commands.
4517| setInputValue('')
4518| helpers.setCursorOffset(0)
4519| }
4520| setPastedContents({})
主布局 return:Messages 与 Permission overlay
mainReturn JSX 结构值得整张记忆:
ScrollKeybindingHandler在 CancelRequestHandler 之前挂载,保证有选区时 Ctrl+C 复制而非取消- scrollable 内
<Messages ... toolUseConfirmQueue={toolUseConfirmQueue} />把队列传给列表(用于行内状态,如 in-progress 点) toolPermissionOverlay仅在focusedInputDialog === 'tool-permission'时挂载 PermissionRequest- bottom 槽:permissionStickyFooter + PromptInput + 各类 dialog
全屏时 FullscreenLayout overlay={toolPermissionOverlay} 使权限框浮在 transcript 之上,PgUp/PgDn 仍可滚动背后内容(modal 时 onScroll 被抑制)。
源码引用: src/screens/REPL.tsx · 第 4548–4590 行(共 7050 行)
4548| // The snapshot persists promptCount so it survives compaction
4549| if (feature('COMMIT_ATTRIBUTION')) {
4550| setAppState(prev => ({
4551| ...prev,
4552| attribution: incrementPromptCount(prev.attribution, snapshot => {
4553| void recordAttributionSnapshot(snapshot).catch(error => {
4554| logForDebugging(
4555| `Attribution: Failed to save snapshot: ${error}`,
4556| )
4557| })
4558| }),
4559| }))
4560| }
4561| }
4562|
4563| // Handle speculation acceptance
4564| if (speculationAccept) {
4565| const { queryRequired } = await handleSpeculationAccept(
4566| speculationAccept.state,
4567| speculationAccept.speculationSessionTimeSavedMs,
4568| speculationAccept.setAppState,
4569| input,
4570| {
4571| setMessages,
4572| readFileState,
4573| cwd: getOriginalCwd(),
4574| },
4575| )
4576| if (queryRequired) {
4577| const newAbortController = createAbortController()
4578| setAbortController(newAbortController)
4579| void onQuery([], newAbortController, true, [], mainLoopModel)
4580| }
4581| return
4582| }
4583|
4584| // Remote mode: send input via stream-json instead of local query.
4585| // Permission requests from the remote are bridged into toolUseConfirmQueue
4586| // and rendered using the standard PermissionRequest component.
4587| //
4588| // local-jsx slash commands (e.g. /agents, /config) render UI in THIS
4589| // process — they have no remote equivalent. Let those fall through to
4590| // handlePromptSubmit so they execute locally. Prompt commands and
源码引用: src/screens/REPL.tsx · 第 4568–4571 行(共 7050 行)
4568| speculationAccept.setAppState,
4569| input,
4570| {
4571| setMessages,
REPL 顶部 import 接缝图
文件前 120 行 import 密度极高,可按层归类:
| 层 | 代表 import |
|---|---|
| Ink / 终端 | ink.js, useTerminalSize, VirtualMessageList |
| 组件 | PermissionRequest, PromptInput, MessageSelector, Spinner |
| Hooks | useCanUseTool, useLogMessages, useReplBridge, useGlobalKeybindings |
| 引擎 | query 相关通过 getToolUseContext 间接使用 |
读 REPL 时宜跳过 feature() 条件编译的 require 块,先抓「始终存在」的 PermissionRequest + PromptInput + useCanUseTool 三角关系。
源码引用: src/screens/REPL.tsx · 第 54–90 行(共 7050 行)
54| import { useTerminalNotification } from '../ink/useTerminalNotification.js'
55| import { hasCursorUpViewportYankBug } from '../ink/terminal.js'
56| import {
57| createFileStateCacheWithSizeLimit,
58| mergeFileStateCaches,
59| READ_FILE_STATE_CACHE_SIZE,
60| } from '../utils/fileStateCache.js'
61| import {
62| updateLastInteractionTime,
63| getLastInteractionTime,
64| getOriginalCwd,
65| getProjectRoot,
66| getSessionId,
67| switchSession,
68| setCostStateForRestore,
69| getTurnHookDurationMs,
70| getTurnHookCount,
71| resetTurnHookDuration,
72| getTurnToolDurationMs,
73| getTurnToolCount,
74| resetTurnToolDuration,
75| getTurnClassifierDurationMs,
76| getTurnClassifierCount,
77| resetTurnClassifierDuration,
78| } from '../bootstrap/state.js'
79| import { asSessionId, asAgentId } from '../types/ids.js'
80| import { logForDebugging } from '../utils/debug.js'
81| import { QueryGuard } from '../utils/QueryGuard.js'
82| import { isEnvTruthy } from '../utils/envUtils.js'
83| import { formatTokens, truncateToWidth } from '../utils/format.js'
84| import { consumeEarlyInput } from '../utils/earlyInput.js'
85|
86| import { setMemberActive } from '../utils/swarm/teamHelpers.js'
87| import {
88| isSwarmWorker,
89| generateSandboxRequestId,
90| sendSandboxPermissionRequestViaMailbox,
源码引用: src/screens/REPL.tsx · 第 16–17 行(共 7050 行)
16| // eslint-disable-next-line custom-rules/prefer-use-keybindings -- / n N Esc [ v are bare letters in transcript modal context, same class as g/G/j/k in ScrollKeybindingHandler
17| import { useInput } from '../ink.js'
源码目录
REPL 单文件过大,建议结合子章节 message-components / permissions-ui 分块阅读。
Spinner、dialog 与 hasActivePrompt
REPL 用 showSpinner 与 hasActivePrompt 协调「模型仍在思考」与「用户必须先点击 Allow」两种等待:
- 当
toolUseConfirmQueue.length > 0时,通常 不显示 主 spinner(避免用户以为系统卡住,实际在等权限) promptQueue承载 Hook 系统的 PromptDialog 请求(与 permissions 不同,来自 utils/hooks.ts)elicitation.queue、workerSandboxPermissions与 sandboxPermissionRequestQueue 各自映射 focusedInputDialog 枚举值
hasSuppressedDialogs 传给 PromptInput,在任一队列非空时抑制底部快捷键,防止与 Select 组件抢键。理解这张表后,调试「弹窗出现但键盘无响应」时先查 focusedInputDialog 当前值,再查对应队列 head 元素是否为空。
源码引用: src/screens/REPL.tsx · 第 1672–1690 行(共 7050 行)
1672| typeof action === 'function' ? action(messagesRef.current) : action
1673| messagesRef.current = next
1674| if (next.length < userInputBaselineRef.current) {
1675| // Shrank (compact/rewind/clear) — clamp so placeholderText's length
1676| // check can't go stale.
1677| userInputBaselineRef.current = 0
1678| } else if (next.length > prev.length && userMessagePendingRef.current) {
1679| // Grew while the submitted user message hasn't landed yet. If the
1680| // added messages don't include it (bridge status, hook results,
1681| // scheduled tasks landing async during processUserInputBase), bump
1682| // baseline so the placeholder stays visible. Once the user message
1683| // lands, stop tracking — later additions (assistant stream) should
1684| // not re-show the placeholder.
1685| const delta = next.length - prev.length
1686| const added =
1687| prev.length === 0 || next[0] === prev[0]
1688| ? next.slice(-delta)
1689| : next.slice(0, delta)
1690| if (added.some(isHumanTurn)) {
源码引用: src/screens/REPL.tsx · 第 2026–2032 行(共 7050 行)
2026| lastQueryCompletionTimeRef.current = lastQueryCompletionTime
2027|
2028| // Aggregate tool result budget: per-conversation decision tracking.
2029| // When the GrowthBook flag is on, query.ts enforces the budget; when
2030| // off (undefined), enforcement is skipped entirely. Stale entries after
2031| // /clear, rewind, or compact are harmless (tool_use_ids are UUIDs, stale
2032| // keys are never looked up). Memory is bounded by total replacement count
动手练习
- 触发一次需确认的 Bash,观察 tab waitingFor 是否变为 approve Bash
- 按 Esc 或 interrupt 绑定,对照 PermissionRequest 的 onDone/onReject 链
- 切换 transcript 模式(若环境支持),确认 PromptInput 未挂载时 editorStatus 显示位置
- 在源码树点击 REPL.tsx 4519 行,跳转到 toolPermissionOverlay 源码块
- 对比 loading 与 waiting 两种状态下终端标题动画与防休眠是否按预期切换
本章小结与延伸
REPL = 会话编排中枢。权限弹窗见 permissions-ui 章;消息行见 message-components;输入见 prompt-input。 继续学习: