本章总览
本章从 screens 层 讲解 REPL:export type Screen、prompt/transcript 视图切换、以及 replLauncher.tsx 如何把 REPL 挂到 Ink 根节点。这与 components/repl 不同——后者深入 toolUseConfirmQueue、canUseTool、FullscreenLayout slot 等编排细节。读完本章你应能回答:「Screen 类型定义在哪?」「transcript 模式何时 early-return?」「ResumeConversation 如何把 initialMessages 交给 REPL?」
学完本章你应该能
- 说明 Screen = prompt | transcript 的语义与下游消费方(Messages、useGlobalKeybindings)
- 描述 launchRepl 动态 import 链:App → REPL
- 解释 transcript 分支的 virtualScroll / dumpMode 降级路径
- 区分 screens/REPL Props 与 ResumeConversation 传入的 initial* 字段
- 定位 REPL 导出类型被 hooks/components 引用的接缝
核心概念(先读懂这些)
Screen 是 REPL 内部视图模式,不是应用路由
应用级「页面」有 REPL、ResumeConversation、Doctor 三个 React 根组件;而 Screen 类型仅描述 REPL 内部主区域是 prompt(底部 PromptInput 可见)还是 transcript(全文虚拟滚动、搜索、Ctrl+E 导出)。hooks 如 useCancelRequest、useGlobalKeybindings 通过 import type { Screen } from screens/REPL 读取该类型,保证快捷键行为与当前视图一致。
launchRepl 是最小挂载壳
replLauncher.tsx 仅 22 行动态 import ``,把 AppState 初始化留给 main.tsx。这与 launchResumeChooser 对称——后者包 KeybindingSetup + ResumeConversation。screens 层的职责是「选对根组件 + 传 Props」,不是启动 query 循环。
建议学习步骤
- 阅读 Screen 类型与 REPL Props 导出(源码块 A)
- 阅读 screen state 初始化与 useSkillsChange(源码块 B)
- 阅读 transcript early-return 分支(源码块 C)
- 阅读 replLauncher 挂载(源码块 D)
- 对照 components/repl 章节的 canUseTool 注册,理解两层分工
常见误区
注意
勿把 mod-components/repl 与 mod-screens/repl-screen 混为一篇——前者编排,后者路由与 Screen 类型
注意
transcript 模式下 PromptInput 不挂载,editorStatus 改在 footer 内联展示
注意
frozenTranscriptState 冻结消息长度,防止 transcript 导出时内存尖峰
screens 层在架构中的位置
Claude Code 终端 UI 可分层理解:
entrypoints/cli.tsx → main.tsx
├─ launchRepl(root, appProps, replProps) → screens/REPL
├─ launchResumeChooser(...) → screens/ResumeConversation → REPL
└─ /doctor → screens/Doctor
screens/REPL.tsx 内部:
useState<Screen>('prompt' | 'transcript')
prompt → 常规聊天 + PromptInput bottom slot
transcript → VirtualMessageList 全文 + TranscriptSearchBar
跨模块 type 导出: export type Props 与 export type Screen 被 replLauncher、ResumeConversation(间接)、Messages、MessageRow、CompactSummary、useGlobalKeybindings 等引用。修改 Screen 联合类型时需全仓搜索 from '../screens/REPL'。
与 components/repl 的分工表:
| 主题 | screens/repl-screen(本章) | components/repl |
|---|---|---|
| Screen prompt/transcript | 定义、切换条件、transcript JSX 分支 | 提及但不展开 |
| toolUseConfirmQueue | 不展开 | 队列、overlay、canUseTool |
| Messages 传参 | transcript 分支的 props 快照 | 主路径 displayedMessages |
| 挂载入口 | replLauncher、main.tsx | REPL return JSX 结构 |
Screen 类型与 REPL Props 导出
REPL 文件末尾前导出两个关键类型:
- Props — 会话启动参数:initialMessages、remoteSessionConfig、sshSession、thinkingConfig 等
- Screen — 仅两值:
'prompt' | 'transcript'
ResumeConversation 在 resumeData 就绪后直接 <REPL initialMessages={...} ... />,不再经过 Resume 专用 Screen 枚举。CLI --resume-session 路径在 main.tsx 预加载消息后同样 launchRepl。
阅读要点:
disabledProp 隐藏输入并禁用 message selectortaskListId启用任务列表自动处理模式thinkingConfig为必填——screens 层挂载时必须传入
源码引用: src/screens/REPL.tsx · 第 571–598 行(共 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
源码引用: src/screens/REPL.tsx · 第 540–570 行(共 7050 行)
540| /* eslint-disable @typescript-eslint/no-require-imports */
541| const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL')
542| ? (require('../tools/WebBrowserTool/WebBrowserPanel.js') as typeof import('../tools/WebBrowserTool/WebBrowserPanel.js'))
543| : null
544| /* eslint-enable @typescript-eslint/no-require-imports */
545| import { IssueFlagBanner } from '../components/PromptInput/IssueFlagBanner.js'
546| import { useIssueFlagBanner } from '../hooks/useIssueFlagBanner.js'
547| import {
548| CompanionSprite,
549| CompanionFloatingBubble,
550| MIN_COLS_FOR_FULL_SPRITE,
551| } from '../buddy/CompanionSprite.js'
552| import { DevBar } from '../components/DevBar.js'
553| // Session manager removed - using AppState now
554| import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js'
555| import { REMOTE_SAFE_COMMANDS } from '../commands.js'
556| import type { RemoteMessageContent } from '../utils/teleport/api.js'
557| import {
558| FullscreenLayout,
559| useUnseenDivider,
560| computeUnseenDivider,
561| } from '../components/FullscreenLayout.js'
562| import {
563| isFullscreenEnvEnabled,
564| maybeGetTmuxMouseHint,
565| isMouseTrackingEnabled,
566| } from '../utils/fullscreen.js'
567| import { AlternateScreen } from '../ink/components/AlternateScreen.js'
568| import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js'
569| import {
570| useMessageActions,
screen state 与命令热重载
const [screen, setScreen] = useState<Screen>('prompt') 与并列状态:
showAllInTranscript— transcript 是否展示全部消息(含 thinking)dumpMode— 诊断用:强制 flat render 到终端 scrollback,供 tmux 原生搜索editorStatus— v-for-editor 渲染进度;transcript 时 PromptInput 未挂载,进度显示在 footer
同一段还展示 useSkillsChange 监听 skill 文件变更并重载 localCommands。这是 screens 层「会话壳」与 skills 模块的接缝:skill 变更不重启 REPL,只 refresh 斜杠命令表。
注释强调 standaloneAgentContext 在 main.tsx 或 ResumeConversation 通过 setAppState 初始化,避免 mount useEffect 违反 CLAUDE.md 指南。
源码引用: src/screens/REPL.tsx · 第 676–684 行(共 7050 行)
676| <Box flexGrow={1} />
677| <Text>{status} </Text>
678| </>
679| ) : searchBadge ? (
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>
源码引用: src/screens/REPL.tsx · 第 703–712 行(共 7050 行)
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. */
711| onClose: (lastQuery: string) => void
712| /** Esc/ctrl+c/ctrl+g — undo to pre-/ state. */
transcript 模式 early-return
当 screen === 'transcript' 时,REPL 在 main return 之前 early-return 整棵 transcript JSX 树,与 prompt 模式互斥。
设计要点:
- virtualScrollActive — 全屏 + 未 disableVirtualScroll + 非 dumpMode 时启用 VirtualMessageList
- transcriptMessages — 使用 frozenTranscriptState 切片,避免导出时克隆整个 deferred 数组
- TranscriptSearchBar — searchOpen 时 bottom 槽替换为搜索栏;Enter 提交、Esc 恢复 searchQuery
- ScrollKeybindingHandler 必须在 CancelRequestHandler 之前,保证有选区时 Ctrl+C 复制
kill switch:CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL 或 dumpMode 时 fall through 到 legacy 30 条 cap + Ctrl+E 路径。
这与 components/repl 章节描述的 mainReturn 是 同一文件的两条渲染路径;本章聚焦分支条件,编排章节聚焦 prompt 路径的权限 overlay。
源码引用: src/screens/REPL.tsx · 第 4380–4402 行(共 7050 行)
4380| // Inject meta messages (model-visible, user-hidden) into the transcript
4381| if (doneOptions?.metaMessages?.length) {
4382| newMessages.push(
4383| ...doneOptions.metaMessages.map(content =>
4384| createUserMessage({ content, isMeta: true }),
4385| ),
4386| )
4387| }
4388| if (newMessages.length) {
4389| setMessages(prev => [...prev, ...newMessages])
4390| }
4391| // Restore stashed prompt after local-jsx command completes.
4392| // The normal stash restoration path (below) is skipped because
4393| // local-jsx commands return early from onSubmit.
4394| if (stashedPrompt !== undefined) {
4395| setInputValue(stashedPrompt.text)
4396| helpers.setCursorOffset(stashedPrompt.cursorOffset)
4397| setPastedContents(stashedPrompt.pastedContents)
4398| setStashedPrompt(undefined)
4399| }
4400| }
4401|
4402| // Build context for the command (reuses existing getToolUseContext).
源码引用: src/screens/REPL.tsx · 第 4392–4428 行(共 7050 行)
4392| // The normal stash restoration path (below) is skipped because
4393| // local-jsx commands return early from onSubmit.
4394| if (stashedPrompt !== undefined) {
4395| setInputValue(stashedPrompt.text)
4396| helpers.setCursorOffset(stashedPrompt.cursorOffset)
4397| setPastedContents(stashedPrompt.pastedContents)
4398| setStashedPrompt(undefined)
4399| }
4400| }
4401|
4402| // Build context for the command (reuses existing getToolUseContext).
4403| // Read messages via ref to keep onSubmit stable across message
4404| // updates — matches the pattern at L2384/L2400/L2662 and avoids
4405| // pinning stale REPL render scopes in downstream closures.
4406| const context = getToolUseContext(
4407| messagesRef.current,
4408| [],
4409| createAbortController(),
4410| mainLoopModel,
4411| )
4412|
4413| const mod = await matchingCommand.load()
4414| const jsx = await mod.call(onDone, context, commandArgs)
4415|
4416| // Skip if onDone already fired — prevents stuck isLocalJSXCommand
4417| // (see processSlashCommand.tsx local-jsx case for full mechanism).
4418| if (jsx && !doneWasCalled) {
4419| // shouldHidePromptInput: false keeps Notifications mounted
4420| // so the onDone result isn't lost
4421| setToolJSX({
4422| jsx,
4423| shouldHidePromptInput: false,
4424| isLocalJSXCommand: true,
4425| })
4426| }
4427| }
4428| void executeImmediateCommand()
源码引用: src/screens/REPL.tsx · 第 4342–4365 行(共 7050 行)
4342| display?: CommandResultDisplay
4343| metaMessages?: string[]
4344| },
4345| ): void => {
4346| doneWasCalled = true
4347| setToolJSX({
4348| jsx: null,
4349| shouldHidePromptInput: false,
4350| clearLocalJSX: true,
4351| })
4352| const newMessages: MessageType[] = []
4353| if (result && doneOptions?.display !== 'skip') {
4354| addNotification({
4355| key: `immediate-${matchingCommand.name}`,
4356| text: result,
4357| priority: 'immediate',
4358| })
4359| // In fullscreen the command just showed as a centered modal
4360| // pane — the notification above is enough feedback. Adding
4361| // "❯ /config" + "⎿ dismissed" to the transcript is clutter
4362| // (those messages are type:system subtype:local_command —
4363| // user-visible but NOT sent to the model, so skipping them
4364| // doesn't change model context). Outside fullscreen the
4365| // transcript entry stays so scrollback shows what ran.
replLauncher:screens 层挂载 REPL
launchRepl 是 REPL 进入 Ink 树的唯一 launcher(ResumeConversation 选中后同样最终进入 REPL,但由 Resume 组件内部 render,不再次调用 launchRepl)。
函数签名:
launchRepl(root, appProps, replProps, renderAndRun)
→ dynamic import App + REPL
→ renderAndRun(root, <App {...appProps}><REPL {...replProps} /></App>)
AppWrapperProps 携带 getFpsMetrics、stats、initialState——App 组件负责 AppStateStore Provider,REPL 只消费 useAppState。
性能:dynamic import 使非交互路径(如 cli --version)不加载 5000 行 REPL 模块。
源码引用: src/replLauncher.tsx · 第 12–22 行(共 29 行)
12| }
13|
14| export async function launchRepl(
15| root: Root,
16| appProps: AppWrapperProps,
17| replProps: REPLProps,
18| renderAndRun: (root: Root, element: React.ReactNode) => Promise<void>,
19| ): Promise<void> {
20| const { App } = await import('./components/App.js')
21| const { REPL } = await import('./screens/REPL.js')
22| await renderAndRun(
源码引用: src/replLauncher.tsx · 第 1–11 行(共 29 行)
1| import React from 'react'
2| import type { StatsStore } from './context/stats.js'
3| import type { Root } from './ink.js'
4| import type { Props as REPLProps } from './screens/REPL.js'
5| import type { AppState } from './state/AppStateStore.js'
6| import type { FpsMetrics } from './utils/fpsTracker.js'
7|
8| type AppWrapperProps = {
9| getFpsMetrics: () => FpsMetrics | undefined
10| stats?: StatsStore
11| initialState: AppState
ResumeConversation → REPL 的 Props 传递
ResumeConversation 不定义 Screen 类型;选中 log 后 setResumeData,下一 render 直接:
<REPL
initialMessages={resumeData.messages}
initialFileHistorySnapshots={...}
initialContentReplacements={...}
initialAgentName={...}
...
/>
REPL 注释(约 677、1980 行)说明 standaloneAgentContext 与 --resume-session CLI 路径共用同一初始化约定。screens 层读者应记住:Resume 不是 REPL 的兄弟 Screen 枚举值,而是 REPL 的前置 picker 组件。
跨项目 resume 时展示 CrossProjectMessage,复制 cd ... && claude --resume ... 到剪贴板而非 mount REPL。
源码引用: src/screens/REPL.tsx · 第 676–678 行(共 7050 行)
676| <Box flexGrow={1} />
677| <Text>{status} </Text>
678| </>
源码引用: src/screens/ResumeConversation.tsx · 第 296–298 行(共 469 行)
296|
297| const standaloneAgentContext = computeStandaloneAgentContext(
298| result.agentName,
Screen 类型的下游消费者
以下模块 import Screen 类型但不在 screens/ 目录:
| 消费者 | 用途 |
|---|---|
hooks/useGlobalKeybindings.tsx | transcript 下禁用部分全局快捷键 |
hooks/useCancelRequest.ts | 取消请求时考虑当前 screen |
components/Messages.tsx | 按 screen 调整渲染(如 transcript verbose) |
components/MessageRow.tsx | 行内 action 在 transcript 模式隐藏 |
components/CompactSummary.tsx | compact 摘要展示与 screen 联动 |
修改 Screen 联合类型时,必须同步这些消费方的分支逻辑,否则会出现 transcript 下仍显示 prompt 专属 UI 的 regression。
源码引用: src/hooks/useGlobalKeybindings.tsx · 第 11–11 行(共 265 行)
11| import type { Screen } from '../screens/REPL.js'
源码引用: src/components/Messages.tsx · 第 17–17 行(共 1159 行)
17| import type { Tools } from '../Tool.js'
本章小结与延伸
screens 层的 REPL = 顶层全屏页 + Screen 视图枚举 + launcher 挂载。编排细节见 components/repl;会话恢复见 resume-conversation。 继续学习: