第 06 课:REPL 交互循环

4 阅读6分钟

模块二:启动与状态 | 前置依赖:第 05 课 | 预计学习时间:60 分钟


学习目标

完成本课后,你将能够:

  1. 描述用户输入从 PromptInput 到 query() 的完整处理路径
  2. 解释 REPL 如何管理并发查询(queryGuard 机制)
  3. 说明权限请求弹窗的触发和渲染流程
  4. 理解流式事件如何驱动消息列表更新

6.1 REPL.tsx 概览

screens/REPL.tsx 是整个应用的主屏幕,也是最大的 React 组件文件(895KB)。它是 Agent 循环的指挥中心,协调以下所有子系统:

REPL.tsx 职责清单:
├── 用户输入接收(PromptInput)
├── 消息历史管理
├── query() 调用与流式事件处理
├── 权限请求展示与用户决策
├── 工具执行进度显示
├── 任务列表管理
├── 通知展示
├── 会话恢复
└── 键盘快捷键处理

启动入口

// replLauncher.tsx — REPL 的启动器
async function launchRepl(root, appProps, replProps, renderAndRun) {
  // 动态导入(代码分割)
  const { App } = await import('./components/App.js')
  const { REPL } = await import('./screens/REPL.js')

  // 渲染 App 壳 + REPL 内容
  renderAndRun(
    <App {...appProps}>
      <REPL {...replProps} />
    </App>
  )
}

6.2 输入处理流程

从按键到 onSubmit

用户按下键盘
  │
  ▼
ink/parse-keypress.ts      — 解析终端字节为按键事件
  │
  ▼
ink/hooks/use-input.ts     — useInput hook 传递给组件
  │
  ▼
PromptInput 组件            — 管理输入框状态(文本、光标、选区)
  │  支持多行编辑、Vim 模式、自动补全
  │
  ▼ (按下 Enter)
onSubmit(input, helpers, speculationAccept?, options?)

onSubmit 的处理逻辑

// REPL.tsx 约第 3142 行
const onSubmit = useCallback(async (
  input: string,
  helpers: PromptInputHelpers,
  speculationAccept?: boolean,
  options?: { fromKeybinding?: boolean }
) => {
  // ① 检查是否是立即命令(/clear, /help 等)
  if (isImmediateCommand(input)) {
    executeImmediately(input)
    return
  }

  // ② 扩展粘贴的文本引用
  expandPastedTextRefs(input)

  // ③ 普通输入 → 路由到 handlePromptSubmit
  handlePromptSubmit(input, helpers)
}, [deps])

命令分流

用户输入
  │
  ├── /clear      → 立即清空消息历史
  ├── /help       → 立即显示帮助
  ├── /compact    → 触发手动压缩
  ├── /model xxx  → 切换模型
  ├── /skill xxx  → 调用技能
  │
  └── (普通文本)  → handlePromptSubmit()
                      → 创建 UserMessage
                      → 调用 onQuery()

6.3 Query 调用链路

onQuery:查询入口

// REPL.tsx 约第 2855 行
const onQuery = useCallback(async (messages, context) => {
  // ① 并发保护:同一时间只允许一个 query
  if (!queryGuard.tryStart()) {
    return  // 已有查询在进行中,拒绝新查询
  }

  try {
    // ② 重置计时器和状态
    resetTiming()
    setMessages(messages)

    // ③ 调用实际实现
    await onQueryImpl(messages, context)
  } finally {
    queryGuard.end()
  }
}, [deps])

onQueryImpl:实际执行

// REPL.tsx 约第 2661 行
const onQueryImpl = useCallback(async (messages, context) => {
  // 调用 query() 异步生成器
  for await (const event of query({
    messages,
    systemPrompt,
    userContext: getUserContext(),
    systemContext: getSystemContext(),
    canUseTool,                     // 权限检查函数
    toolUseContext,                 // 工具执行上下文
    querySource,                   // 查询来源标记
  })) {
    // 处理每个流式事件
    handleMessageFromStream(event)
  }
}, [deps])

queryGuard:并发保护

为什么需要并发保护?

场景:用户快速按两次 Enter
  第一次 Enter → onQuery #1 开始 → API 调用中...
  第二次 Enter → onQuery #2 开始 → ❌ 危险!

没有 queryGuard 的后果:
  - 两个 query 同时修改消息列表 → 状态混乱
  - 两个 API 请求同时进行 → 费用浪费
  - 工具执行交叉 → 不可预测的结果

有 queryGuard:
  第一次 Enter → onQuery #1 开始 → queryGuard.tryStart() = true ✓
  第二次 Enter → onQuery #2 尝试 → queryGuard.tryStart() = false ✗
                                    → 请求被忽略

6.4 流式事件处理

query() 返回的是一个 AsyncGenerator,每次 yield 一个事件。REPL 通过 for await 循环逐个处理:

事件类型

StreamEvent 类型:
├── RequestStartEvent     — API 请求开始
├── AssistantMessage      — Claude 的文字响应(部分或完整)
├── ToolUseSummaryMessage — 工具调用摘要
├── TombstoneMessage      — 被压缩消息的墓碑
├── ProgressMessage       — 工具执行进度
└── Message               — 通用消息

handleMessageFromStream

// 简化的流式事件处理
function handleMessageFromStream(event: StreamEvent) {
  switch (event.type) {
    case 'request_start':
      // 更新 UI:显示"思考中..."
      break

    case 'assistant_message':
      // 追加到消息列表,触发 UI 更新
      // 如果包含 tool_use 块,准备工具执行
      appendMessage(event)
      break

    case 'tool_use_summary':
      // 显示工具调用的简要描述
      appendSummary(event)
      break

    case 'progress':
      // 更新进度条(如 Bash 命令的实时输出)
      updateProgress(event)
      break
  }
}

流式渲染的效果

时间 ─────────────────────────────────────────►

API 返回流:
  "让" → "让我" → "让我看看" → "让我看看这个文件" → [tool_use: Read]

用户看到的终端:
  t=0: 让
  t=1: 让我
  t=2: 让我看看
  t=3: 让我看看这个文件
  t=4: [权限请求弹窗:是否允许读取 /src/index.ts?]

6.5 权限交互

权限检查链路

query() 循环中检测到 tool_use
  │
  ▼
findToolByName('Read')
  │
  ▼
tool.checkPermissions(input)
  │
  ├── behavior: 'allow'  → 直接执行
  ├── behavior: 'deny'   → 返回拒绝消息
  └── behavior: 'ask'    → 需要用户确认
       │
       ▼
    REPL 渲染 PermissionRequest 组件
       │
       ├── 用户点击 Allow  → 执行工具 → 结果返回
       ├── 用户点击 Deny   → 拒绝消息 → denialTracking 记录
       └── 用户点击 Always → 持久化规则 → 执行工具

权限相关 Hooks

// REPL.tsx 中的权限相关导入和 hooks

// 核心权限检查
const canUseTool = useCanUseTool()

// 权限状态管理
const applyPermissionUpdate = useApplyPermissionUpdate()
const persistPermissionUpdate = usePersistPermissionUpdate()

// 自动模式的安全降级
const stripDangerousPermissionsForAutoMode = useStripDangerousPermissions()

// 旁路开关监控
const bypassPermissionsKillswitch = useBypassKillswitch()

// 沙箱管理(网络访问等)
const sandboxManager = useSandboxManager()

多 Agent 场景的权限同步

在 Coordinator-Worker 模式中,Worker Agent 的权限请求需要传递给用户:

Worker Agent 请求权限
  │
  ▼
sendSandboxPermissionRequestViaMailbox()
  │  通过 Agent 间邮箱传递
  ▼
REPL 接收到权限请求
  │
  ▼
WorkerPendingPermission 组件渲染
  │
  ▼
用户决策 → 通过邮箱返回给 Worker

6.6 会话管理

三种屏幕

screens/
├── REPL.tsx                — 主交互屏幕(新会话或活跃会话)
├── ResumeConversation.tsx  — 会话恢复屏幕(选择历史会话)
└── Doctor.tsx              — 健康检查屏幕(诊断问题)

会话恢复

用户运行 `claude --resume`
  │
  ▼
ResumeConversation.tsx
  │  显示历史会话列表
  │  用户选择一个会话
  ▼
加载会话消息历史
  │
  ▼
REPL.tsx(带历史消息初始化)
  │
  ▼
继续之前的对话

6.7 REPL 的完整渲染结构

<REPL>
  ├── <Header />                    — 顶部状态栏
  │
  ├── <VirtualMessageList>          — 消息列表(虚拟滚动)
  │   ├── <UserMessage />           — 用户消息
  │   ├── <AssistantMessage />      — Claude 响应
  │   ├── <ToolUseMessage />        — 工具调用展示
  │   └── <ToolResultMessage />     — 工具结果展示
  │
  ├── <TaskList />                  — 后台任务列表
  │
  ├── <PermissionRequest />         — 权限请求弹窗
  ├── <WorkerPendingPermission />   — Worker 权限请求
  ├── <SandboxPermissionRequest />  — 沙箱权限请求
  │
  ├── <NotificationArea />          — 通知区域
  │
  ├── <PromptInput />               — 用户输入框
  │   ├── 多行编辑
  │   ├── Vim 模式(可选)
  │   └── 自动补全
  │
  └── <Footer />                    — 底部快捷键提示

课后练习

练习 1:输入追踪

在 REPL.tsx 中搜索 onSubmit,从用户按 Enter 开始,逐步追踪到 query() 被调用。列出中间经过的所有函数/回调。

练习 2:queryGuard 重现

编写一个简化的 queryGuard 实现(不超过 15 行),支持 tryStart()end() 方法。思考:它本质上是什么并发原语?

练习 3:事件流模拟

假设用户输入"读取 package.json 并告诉我版本号",列出 REPL 会收到的所有流式事件类型和它们的大致顺序。

练习 4:权限场景分析

对以下场景,判断权限检查的结果(allow/ask/deny):

  • a) FileReadTool 读取 /src/index.ts
  • b) BashTool 执行 rm -rf /
  • c) BashTool 执行 git status
  • d) FileEditTool 修改 ~/.bashrc

本课小结

要点内容
REPL 职责输入接收、query 调用、权限管理、消息渲染
输入流PromptInput → onSubmit → 命令分流 → query()
并发保护queryGuard 确保同一时间只有一个活跃查询
流式处理AsyncGenerator + for await 逐事件处理
权限交互checkPermissions → ask → PermissionRequest 组件
会话管理新会话(REPL) / 恢复(Resume) / 诊断(Doctor)

下一课预告

第 07 课:query.ts — Agent 循环的心脏 — 深入 Agent 循环的核心实现,理解 API 调用、流式处理、工具分发、错误恢复和上下文压缩触发的完整逻辑。