Claude Code 的原始碼外洩後,發現它的核心互動介面 src/screens/REPL.tsx 居然有 5005 行。單一檔案,單一函式式元件。
好奇心驅使我通讀了一遍。大約 290 個 import、60+ 個 useState、30+ 個 useEffect、20+ 個 useCallback。這個元件跑在 Ink(React 的終端渲染器)上,承載了 Claude Code CLI 幾乎所有的互動邏輯。
讀完之後感觸很複雜——有些地方寫得確實漂亮,有些地方你能感覺到是被 deadline 推著走的妥協。記錄如下。
REPL 就是 Read-Eval-Print Loop。打開終端輸入 claude,你看到的整個介面就是這個元件在渲染。它負責:
技術棧是 React 19 + React Compiler + Ink + TypeScript,建構工具是 Bun。
const useVoiceIntegration = feature('VOICE_MODE')
? require('../hooks/useVoiceIntegration.js').useVoiceIntegration
: () => ({ stripTrailing: () => 0, handleKeyEvent: () => {}, resetAnchor: () => {} });
feature() 是 Bun 的編譯期常數。建構時,沒開的功能連那行 require 都會被消除掉,包括它引入的整個模組相依樹。
巧妙之處在於 stub(替代實作)的設計。給一個回傳空操作的函式,而不是 null。這樣後面對 useVoiceIntegration() 的呼叫就可以照常呼叫,不需要到處寫 if (feature('VOICE_MODE')) 做守衛,Hook 呼叫順序也不會被打亂。用 typeof import(...) 去約束 stub 的簽名與真實實作一致,在型別層面就堵住了不匹配的風險。
整個檔案有十幾處這種模式,涵蓋語音輸入、挫折檢測、組織告警、Coordinator 模式等內部功能。對外發佈版本的產物裡,這些程式碼物理上就不存在。比起執行時的 flag 判斷乾淨太多了。
const queryGuard = React.useRef(new QueryGuard()).current;
const isQueryActive = React.useSyncExternalStore(queryGuard.subscribe, queryGuard.getSnapshot);
大部分 React 應用處理「是否在載入」通常就是一個 useState(false)。但 Claude Code 面對的情境比一般應用複雜——使用者可以快速按 Enter 提交、Esc 取消、再按 Enter 重新提交,中間還可能有後台 agent 的通知觸發新查詢。
傳統的 useState + useRef 雙寫模式在這種情境下很容易翻車,因為 React 的 setState 是非同步批次的,ref 與 state 之間會出現時間窗口不同步的問題。
QueryGuard 把這個問題建模成一個狀態機,四個原子操作(reserve / tryStart / end / forceEnd),加上一個 generation 計數器。當使用者按 Esc 取消再立即重新提交時,舊查詢的 finally block 拿到的 generation 跟當前不匹配,就知道自己已經過時了,不會去清理新查詢的狀態。
透過 useSyncExternalStore 暴露給 React,不需要手動 setState,訂閱者會自動感知變化。這是正確處理此類問題的方式,但說實話在業界能看到這種做法的專案不多。
const setMessages = useCallback((action) => {
const prev = messagesRef.current;
const next = typeof action === 'function' ? action(messagesRef.current) : action;
messagesRef.current = next; // 同步寫入 ref
rawSetMessages(next); // 非同步通知 React
}, []);
React 的 setState 是非同步的,但很多回呼需要同步讀到最新值。常規做法是在 useEffect 裡同步 ref,但會有一幀延遲。
Claude Code 直接在 setState 的封裝裡先寫 ref,再把算好的結果(注意不是 updater 函式)傳給真正的 rawSetMessages。程式碼註解裡稱這是「Zustand 模式」——ref 是真實資料來源(source of truth),React state 是它的渲染投影。
這個模式在檔案裡被反覆使用:messagesRef、inputValueRef、streamModeRef、abortControllerRef、focusedInputDialogRef……大概有七八處。如果你的 React 應用也有「非同步回呼裡讀到的狀態總是舊的」這個痛點,這是目前最實用的解法。
這個檔案裡的效能優化不是那種「加個 memo 就解決」的程度,而是基於對 React 渲染模型的系統性理解來做的:
⠂ / ⠐ 交替)。如果把 setInterval 放在 REPL 主元件裡,每次 tick 會讓整棵樹 re-render。解法是抽出一個 AnimatedTerminalTitle 元件,回傳 null(純副作用),tick 只觸發這個空元件的 re-render。streamMode 在串流回應期間大約切換多次。如果把它放進 onSubmit 的依賴陣列,每次切換都會重建 onSubmit,導致輸入區整片 re-render。解法是用 ref 鏡像,回呼透過 ref 讀取,React 渲染不會感知這種變化。useDeferredValue(messages) 產生一個延遲版本的訊息列表。串流回應期間,Spinner 與輸入框使用即時的 messages,訊息列表則使用延遲的 deferredMessages,這樣長列表的 reconciliation 不會阻塞輸入。但當串流文字正在顯示或查詢結束時,又切回即時訊息,避免「動畫停了但回覆還沒出來」的閃爍。const usesSyncMessages = showStreamingText || !isLoading;
const displayedMessages = usesSyncMessages ? messages : deferredMessages;
這種條件切換的思路比單純套用 useDeferredValue 更精細。
我看過不少開源專案的程式碼,這個檔案的註解水準在第一梯隊。不是那種「把 loading 設為 true」的廢話註解,而是記錄「為什麼」和「不這樣做會如何」:
// Josh Rosen's workflow: Claude emits long output → scroll
// up to read the start → start typing → before this fix, snapped to bottom.
// https://anthropic.slack.com/archives/C07VBSHV7EV/p1773545449871739
const RECENT_SCROLL_REPIN_WINDOW_MS = 3000;
一個常數附帶了:具體的使用場景(誰遇到什麼問題)、修復前的行為、內部討論連結。半年後新人看到這段程式碼,不用猜為什麼是 3 秒。
另個例子:
// Without this, paths that queue functional updaters then
// synchronously read the ref (e.g. handleSpeculationAccept →
// onQuery) see stale data.
直接告訴你:不加這行,哪個呼叫鏈會讀到髒資料。這種註解的信息密度有時比程式碼本身還高。
使用者按 Esc 中斷 Claude 的回覆時,如果 Claude 還沒產生什麼有用內容,REPL 會自動回滾對話、恢復你之前輸入的文字,省去重新打字的麻煩。
實作上卡了 5 個條件:中斷原因必須是使用者主動取消(不是程式性中斷)、沒有新查詢在跑、輸入框是空的(不覆蓋使用者已經開始打的新內容)、命令佇列是空的、且不在看 teammate 的視圖。
這種細節不是架構層面的東西,但直接影響日常使用的手感。能把這些 edge case 一個個堵住,說明有大量真實使用回饋在驅動。
使用者離開超過 75 分鐘、且對話已消耗超過 10 萬 token 時,下次輸入會提示「要不要 /clear 開個新對話」。
長對話的 KV 快取已經冷了,繼續追加 token 成本高、回應品質也可能下降。但這個提示不是強制阻擋——支持阻斷式彈窗和非阻斷式通知兩種形態,透過 A/B 測試(GrowthBook)切換,使用者還能永久關閉。把成本優化做成使用者體驗優化,不讓人覺得「系統在限制我」。
這是最大、也最顯著的問題。
REPL 函式從第 572 行開始,一直到第 5004 行的 return。中間塞了:
messages, conversationId, sessionTitle)screen, showAllInTranscript, dumpMode, editorStatus)inputValue, inputMode, pastedContents, vimMode)queryGuard, isExternalLoading, streamMode, streamingToolUses)toolUseConfirmQueue, promptQueue, sandboxPermissionRequestQueue)focusedInputDialog 類型getFocusedInputDialog 函式(第 2017 行)是一個 30 多行的 if-else 優先級鍊,決定當多個彈窗同時需要顯示時哪個取得焦點:
exit > message-selector > (輸入抑制) > sandbox-permission >
tool-permission > prompt > worker-sandbox > elicitation > cost >
idle-return > ultraplan > ide-onboarding > model-switch > ...
本質上是在手動實作一個狀態機,但沒有用狀態機來表達。新增一個彈窗類型時,必須準確地插在這條鍊的正確位置。
為什麼不拆?我猜有幾個原因:60+ 個 useState 裡大約 40 個被兩個以上的回呼共享,拆出去就要大量 props drilling 或 context;onSubmit → onQuery → getToolUseContext 的回呼依賴鏈很深,跨元件傳遞會更亂;React Compiler 對大元件做了細粒度快取,效能懲罰沒有傳統 React 那麼大。
但更可能的真相是:沒有人特地設計一個 5000 行的元件。它是隨功能演進長出來的。每次加個新功能(voice、swarm、ultraplan、companion sprite),在現有 REPL 裡加幾個 useState 與一段 JSX 是最快的迭代方式。直到有一天發現已經 5000 行了。
onSubmit(第 3142 行)的依賴陣列有 30 多項。這表示其中任何一個值改變,整個回呼都會重建,進而導致 PromptInput 的 props 變化與下游的級聯 re-render。
為了緩解這個問題,檔案裡造了大量 ref 鏡像(onSubmitRef、streamModeRef、terminalFocusRef 等),讓回呼透過 ref 讀取而不是閉包捕獲。
這本身就是一個訊號——當你需要 10 個 ref 來保持一個回呼穩定,說明這個回呼承擔了太多責任。
resume 回呼(第 1735 行)有 213 行,執行 20 多個步驟:反序列化訊息 → 比對 coordinator 模式 → 執行 SessionEnd hooks → 執行 SessionStart hooks → 複製 plan → 恢復檔案歷史 → 恢復 agent 設定 → 恢復成本狀態 → 切換 session → 重命名 asciicast → 重設 session 檔案指標 → 清除/恢復 session metadata → 退出/恢復 worktree → 恢復內容替換 → 重設訊息 → 清除輸入...
這個函式應該是個獨立模組。但它依賴了 REPL 的大量區域性狀態(readFileState、haikuTitleAttemptedRef、bashTools),想抽離出去很困難。這就是 God Component 的典型症狀——所有東西都耦合在一起,想拆任何一塊都牽一髮而動全身。
if (feature('AWAY_SUMMARY')) {
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAwaySummary(messages, setMessages, isLoading);
}
整個檔案有 10 多處這種條件 Hook 的呼叫。feature() 是編譯期常數沒錯,執行時不會變,不會違反 Hook 規則。但這仰賴 Bun 的 DCE 正確工作;TypeScript Server 不會知道這是常數(會標紅,要 suppress),每次 code review 都要人工確認「這真的是編譯期常數」。
更穩健的作法是把條件 Hook 提取為獨立元件,用條件渲染代替條件呼叫:
{feature('AWAY_SUMMARY') && <AwaySummaryProvider messages={messages} ... />}
mainReturn(第 4548 行開始)是一棵巨大的 JSX 樹。15 個以上的彈窗元件嵌在裡面,每個的 onDone / onResponse 回呼直接內聯,最長的 onSummarize 有 40 多行。
{focusedInputDialog === 'idle-return' && idleReturnPending &&
<IdleReturnDialog
idleMinutes={idleReturnPending.idleMinutes}
totalInputTokens={getTotalInputTokens()}
onDone={async action => {
// 40 行回呼邏輯...
}}
/>
}
版面結構被回呼邏輯淹沒。改任何一個彈窗的回呼,git diff 看起來像改了整個渲染樹。想單獨測試某個彈窗的行為?幾乎不可能,它被綁死在 REPL 的 5000 行狀態裡。
const RECENT_SCROLL_REPIN_WINDOW_MS = 3000;
const PROMPT_SUPPRESSION_MS = 1500;
if (turnDurationMs > 30000 || budgetInfo !== undefined) { ... }
if (count >= 3) return; // autoPermissionsNotificationCount
if (wt.creationDurationMs > 15_000) return; // worktree tip threshold
大部分都有命名或註解,但散落在 5000 行的各個角落。想調整某個閾值,得先找到它在哪裡。
檔案裡混用了三種非同步錯誤處理模式:
void someAsyncCall().then(...).catch(...) — 約 20 處 try { await ... } catch { ... } — 約 15 處 void someAsyncCall() 不處理 — 約 5 處沒有統一策略。某些路徑的靜默失敗可能在極端情境下產生莫名其妙的 bug。
檔案裡用了 17 個 feature flag:
VOICE_MODE, COORDINATOR_MODE, PROACTIVE, KAIROS, TOKEN_BUDGET,
BRIDGE_MODE, TRANSCRIPT_CLASSIFIER, BG_SESSIONS, MESSAGE_ACTIONS,
ULTRAPLAN, BUDDY, AWAY_SUMMARY, WEB_BROWSER_TOOL, HOOK_PROMPTS,
CONTEXT_COLLAPSE, COMMIT_ATTRIBUTION, AGENT_TRIGGERS
編譯期消除保證了執行時不會慢,但原始碼層面上,17 個 flag 理論上有 131,072 種程式路徑組合。閱讀程式碼時要不停在腦中過濾「這段在外部建構時存不存在」,心智負擔不小。
logEvent('tengu_session_resumed', {
entrypoint: entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
success: true,
});
AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 這個型別名稱是認真的。它強制每個埋點呼叫者透過 as 斷言來確認「我檢查過了,這個值裡沒有使用者程式碼或檔案路徑」。Code review 時看到這個斷言就知道要額外關注隱私合規。用型別系統來編碼安全策略,思路很好。
檔案裡到處都是 ref 做的一次性守衛:
tipPickedThisTurnRef:防止 resetLoadingState 執行兩次時重複選 spinner tip hasCountedQueueUseRef:防止 saveGlobalConfig 的寫風暴(並發會話下會互相打架) idleHintShownRef:每會話只顯示一次 idle 提示 safeYoloMessageShownRef:auto mode 提示最多顯示 3 次模式是一致的,但每次都手寫。如果提取一個 useOncePerTurn 或 useGuardedEffect 會乾淨很多。
const activeRemote = sshRemote.isRemoteMode
? sshRemote
: directConnect.isRemoteMode
? directConnect
: remoteSession;
SSH、Direct Connect、WebSocket Remote 三種模式透過相同介面(sendMessage、cancelRequest、isRemoteMode)抽象。REPL 只跟 activeRemote 互動,不關心底下是什麼傳輸層。沒有遠端模式時 isRemoteMode 為 false,所有遠端程式路徑自然被跳過。簡單有效。
REPL 同時用了 Zustand 風格的全域 store(AppState)與元件內的 useState。分界線不太清楚:
大致的規則好像是:需要被子 agent、後台任務、MCP handler 讀取的放 AppState,純 UI 狀態放 local。但 messages 作為最核心的狀態卻是 local 的,並透過回呼傳給需要的地方。這導致 getToolUseContext 要同時從 store.getState() 和閉包裡取資料,兩個世界混在一起,增加複雜度。
| 維度 | 好的方面 | 不好的方面 |
|---|---|---|
| 規模 | 功能覆蓋完整 | 單檔案過大,認知負擔重 |
| 效能 | 系統性優化,不只是零碎的優化 | 可讀性差、回呼深度大 |
| 可讀性 | 註解品質極高,很多設計決策有記錄 | JSX 被回呼邏輯淹沒,magic numbers 分散 |
| 可維護性 | 類型安全、編譯期 flag 消除 | 60+ 個 useState,想重構無從下手 |
| 錯誤處理 | 自動還原、防禦性守衛細緻 | 異步錯誤處理模式混用,策略不一致 |
如果要給一個評價:這是技術功底很深的人在高速迭代壓力下寫出來的程式碼。
每一個 useState 都有存在的理由,每一個 useEffect 都解決了真實問題,每一段註解都記錄了一次 bug 修復或一個產品決策。但當 5000 行累積在一個函式裡,整體的可維護性還是不可避免地下降了。
不過話說回來,這可能是工程中最常見也最現實的困境:不是程式碼寫得不好,而是好程式碼在持續迭代中沒有找到結構性重構的時機。寫程式的人比誰都清楚哪裡該拆,但 5005 行的元件與 5005 行的 TODO 之間,前者至少能跑。
—
說到底,這個專案很可能是 Claude Code 自己迭代自己寫出來的。用人類的程式碼審美去評判一個 AI 寫給自己用的程式碼,多少有點錯位。但至少在閱讀過程中能學到很多東西。再往遠處想,也許未來大家真的不用手寫程式了——程式碼只要 AI 自己能看懂就行,那時候「可讀性、可維護性」這些標準可能得重新定義。
基於 Claude Code v2.1.88 原始碼分析,僅供技術交流。