Claude Code 源码分析Claude Code 源码分析
首页
源码统计
系统架构
UML 图表
工具系统
CodeGraph
首页
源码统计
系统架构
UML 图表
工具系统
CodeGraph
  • 概览

    • Claude Code 源码分析
    • 源码统计
    • CodeGraph 图谱
  • 架构

    • 系统架构
    • UML 图表索引
    • 查询引擎
    • 核心流程
    • 消息系统
    • 状态管理
  • 功能模块

    • 工具系统
    • 斜杠命令
    • 服务层
    • MCP 协议
    • Skills 技能
    • 子代理系统
  • 分层深度

    • 入口层
    • UI / Ink 层
    • utils 基础设施
    • 桥接 / 远程
    • 上下文压缩
  • 原理与安全

    • 底层原理
    • 技术难点
    • 权限与安全
    • 内部机制
    • 遥测与分析
  • 深度专题

    • Hooks 系统
    • 插件系统
    • 记忆系统
    • API 通信层
    • Ink 终端 UI
    • 认证系统
    • 构建与发布
    • 术语表
  • 调用分析

    • 调用链分析
    • 核心文件索引
  • 模块详解

    • utils

      • 模块: utils
      • messages · 消息工厂与规范化
      • session-storage · JSONL 会话持久化
      • permissions · 工具权限决策
      • shell-hooks · 用户 Shell Hook 系统
    • components

      • 模块: components
      • REPL · 主屏编排
      • messages · 消息行渲染
      • PermissionRequest · 权限弹窗
      • PromptInput · 底部输入
    • services

      • 模块: services
      • api-claude · Anthropic API 流式与重试
      • mcp-client · MCP 连接与工具调用
      • compact · 上下文压缩与自动触发
      • analytics · GrowthBook、Datadog 与 1P 事件
    • tools

      • 模块: tools
      • tool-interface · Tool 契约与注册表
      • bash-tool · Shell 执行与权限
      • streaming-executor · 流式工具并发调度
      • agent-tool · 子 Agent 委派
    • commands

      • 模块: commands
      • command-registry · commands.ts 注册与分派
      • model-command · /model 模型选择
      • mcp-commands · /mcp 服务器管理
      • compact-memory-commands · /compact 与 /memory
    • ink

      • 模块: ink
      • Ink 渲染管线 · Screen 与终端输出
      • 终端事件 · resize、paste、stdin
      • Ink Hooks · 输入、搜索、终端状态
      • Ink 组件 · Box、Text、ScrollBox 原语
    • hooks

      • 模块: hooks
      • useCanUseTool · 权限 UI 接缝
      • 输入与快捷键 Hook
      • 合并态 Hook(MCP + 本地)
      • notifs 通知 Hook
    • bridge

      • 模块: bridge
      • repl-bridge · REPL 桥初始化与传输
      • bridge-messaging · 桥消息路由与入站处理
      • remote-bridge-core · env-less 核心与守护主循环
      • bridge-permissions-ui · 权限、API 与 TUI
    • cli

      • 模块: cli
      • Structured IO · NDJSON SDK 协议
      • CLI Transports · Session Ingress 传输层
      • CLI Handlers · 子命令懒加载实现
      • Update & Upload · 自更新与串行上传原语
    • screens

      • 模块: screens
      • REPL 屏 · Screen 类型与顶层路由
      • ResumeConversation · 会话恢复选择器
      • Doctor · 安装诊断全屏
    • entrypoints

      • 模块: entrypoints
      • cli-entrypoint · Bootstrap 与快路径
      • sdk-types · core / control / runtime 类型体系
      • mcp-entrypoint · MCP stdio 服务器
      • sandbox-types · 沙箱配置单一真相源
    • skills

      • 模块: skills
      • skills-loading · 磁盘加载与 bundled 注册表
      • bundled-skills · 内置 skill 与 initBundledSkills
      • mcp-skills · MCP prompt 转 skill
      • skill-tool-integration · SkillTool 与命令注册
    • types

      • 模块: types
      • message-types · Message 联合与 content blocks
      • tool-permission-types · Tool、Permission、Command 类型
      • api-sdk-types · API 与 Hooks 协议类型
      • misc-types · ids、plugin、generated 与其余类型
    • tasks

      • 模块: tasks
      • local-agent-task · 本地 Agent 与主会话后台化
      • remote-agent-task · 远程 CCR 与 In-Process Teammate
      • shell-workflow-tasks · Bash 后台、Workflow 与 stopTask
      • dream-monitor-tasks · Dream、Monitor MCP 与 pill 文案
    • keybindings

      • 模块: keybindings
      • keybinding-registry · 注册、Provider 与 useKeybinding
      • default-bindings · 默认键位表与平台差异
      • command-bindings · command:* 动态斜杠命令绑定
      • vim-bindings · Vim 模式与 keybindings 边界
    • memdir

      • 模块: memdir
      • memdir-core · 路径、加载与 MEMORY.md
      • memory-extraction · extractMemories 与 SessionMemory
      • memdir-commands · /memory、/remember 与命令集成
    • state

      • 模块: state
      • app-state-core · store、AppState 类型与 Provider
      • app-state-selectors · selectors 与 onChangeAppState
      • teammate-state · 队友视图与 swarm 状态
      • state-boundaries · bootstrap、sessionStorage、FileStateCache
    • query

      • 模块: query
      • query config 与 deps · 配置快照与依赖注入
      • query tokenBudget · +500k 自动续跑
      • query transitions · Continue / Terminal 状态机
      • query stopHooks · Stop 事件与 turn 结束编排
  • 模块详解(扩展)

    • messages · 消息工厂与规范化
    • session-storage · JSONL 会话持久化
    • permissions · 工具权限决策
    • shell-hooks · 用户 Shell Hook 系统
    • REPL · 主屏编排
    • messages · 消息行渲染
    • PermissionRequest · 权限弹窗
    • PromptInput · 底部输入
    • api-claude · Anthropic API 流式与重试
    • mcp-client · MCP 连接与工具调用
    • compact · 上下文压缩与自动触发
    • analytics · GrowthBook、Datadog 与 1P 事件
    • tool-interface · Tool 契约与注册表
    • bash-tool · Shell 执行与权限
    • streaming-executor · 流式工具并发调度
    • agent-tool · 子 Agent 委派
    • command-registry · commands.ts 注册与分派
    • model-command · /model 模型选择
    • mcp-commands · /mcp 服务器管理
    • compact-memory-commands · /compact 与 /memory
    • Ink 渲染管线 · Screen 与终端输出
    • 终端事件 · resize、paste、stdin
    • Ink Hooks · 输入、搜索、终端状态
    • Ink 组件 · Box、Text、ScrollBox 原语
    • useCanUseTool · 权限 UI 接缝
    • 输入与快捷键 Hook
    • 合并态 Hook(MCP + 本地)
    • notifs 通知 Hook
    • repl-bridge · REPL 桥初始化与传输
    • bridge-messaging · 桥消息路由与入站处理
    • remote-bridge-core · env-less 核心与守护主循环
    • bridge-permissions-ui · 权限、API 与 TUI
    • Structured IO · NDJSON SDK 协议
    • CLI Transports · Session Ingress 传输层
    • CLI Handlers · 子命令懒加载实现
    • Update & Upload · 自更新与串行上传原语
    • REPL 屏 · Screen 类型与顶层路由
    • ResumeConversation · 会话恢复选择器
    • Doctor · 安装诊断全屏
    • cli-entrypoint · Bootstrap 与快路径
    • sdk-types · core / control / runtime 类型体系
    • mcp-entrypoint · MCP stdio 服务器
    • sandbox-types · 沙箱配置单一真相源
    • skills-loading · 磁盘加载与 bundled 注册表
    • bundled-skills · 内置 skill 与 initBundledSkills
    • mcp-skills · MCP prompt 转 skill
    • skill-tool-integration · SkillTool 与命令注册
    • message-types · Message 联合与 content blocks
    • tool-permission-types · Tool、Permission、Command 类型
    • api-sdk-types · API 与 Hooks 协议类型
    • misc-types · ids、plugin、generated 与其余类型
    • local-agent-task · 本地 Agent 与主会话后台化
    • remote-agent-task · 远程 CCR 与 In-Process Teammate
    • shell-workflow-tasks · Bash 后台、Workflow 与 stopTask
    • dream-monitor-tasks · Dream、Monitor MCP 与 pill 文案
    • keybinding-registry · 注册、Provider 与 useKeybinding
    • default-bindings · 默认键位表与平台差异
    • command-bindings · command:* 动态斜杠命令绑定
    • vim-bindings · Vim 模式与 keybindings 边界
    • memdir-core · 路径、加载与 MEMORY.md
    • memory-extraction · extractMemories 与 SessionMemory
    • memdir-commands · /memory、/remember 与命令集成
    • app-state-core · store、AppState 类型与 Provider
    • app-state-selectors · selectors 与 onChangeAppState
    • teammate-state · 队友视图与 swarm 状态
    • state-boundaries · bootstrap、sessionStorage、FileStateCache
    • query config 与 deps · 配置快照与依赖注入
    • query tokenBudget · +500k 自动续跑
    • query transitions · Continue / Terminal 状态机
    • query stopHooks · Stop 事件与 turn 结束编排
  • 工具详解

    • tool-interface · Tool 契约与注册表
    • tool-permission-types · Tool、Permission、Command 类型
    • 工具: Bash
    • 工具: PowerShell
    • 工具: Agent
    • 工具: LSP
    • 工具: FileEdit
    • 工具: FileRead
    • 工具: Skill
    • 工具: WebFetch
    • 工具: MCP
    • 工具: SendMessage
    • 工具: FileWrite
    • 工具: Config
    • 工具: Grep
    • 工具: Brief
    • 工具: ExitPlanMode
    • 工具: ToolSearch
    • 工具: NotebookEdit
    • 工具: TaskOutput
    • 工具: WebSearch
    • 工具: ScheduleCron

本章总览

Claude Code 终端交互的入口是 stdin raw mode 下的字节流。App.tsx 在 readable 上循环 read(),交给 parse-keypress 状态机拆成键、粘贴块、鼠标 SGR、终端查询响应;再经 EventEmitter(legacy)与 Dispatcher(DOM 风格)两路分发。本章覆盖 resize、bracketed paste、stdin 间隙恢复、焦点事件 与 useInput 契约。

学完本章你应该能

  • 说明 rawModeEnabledCount 引用计数与 bracketed paste 启停时机
  • 解释 parse-keypress 对 IN_PASTE 与 incomplete escape 的超时策略
  • 理解 resize 如何同步更新 Yoga 与 TerminalSizeContext
  • 掌握 ResizeEvent 在 Dispatcher 中的优先级与冒泡
  • 能追踪 Ctrl+C、SIGCONT、stdin 5s gap 的终端模式重断言路径

核心概念(先读懂这些)

双通道输入:Emitter 与 DOM Dispatcher

useInput 订阅 internal_eventEmitter 的 input 事件(InputEvent 包装 ParsedKey)。Box 的 onKeyDown 等走 reconciler Dispatcher,collectListeners 按捕获/冒泡顺序执行。同一按键可能两路都收到,组件应 stopImmediatePropagation 或 isActive 门控避免重复处理。

粘贴是原子 input 字符串

Bracketed paste(EBP/DBP)由 App 在 raw mode 开关时写入。解析器进入 IN_PASTE 模式,整段 bracket 内容作为一次 input 回调传给 useInput,避免逐字符风暴。PASTE_TIMEOUT(500ms)长于普通 ESC 超时(50ms)。

resize 同步而非节流

stdout resize 事件在 handleResize 同步更新 terminalColumns/Rows、Yoga 根宽度、needsEraseBeforePaint,并立即 render(currentNode)。注释明确反对在 resize 时 scheduleRender,防止 Yoga 尺寸与帧内容不一致。

建议学习步骤

  1. 阅读 StdinContext 暴露的 API
  2. 阅读 App handleSetRawMode 与 handleReadable
  3. 阅读 parse-keypress 的 PASTE 与 CSI u 正则
  4. 阅读 ink.tsx handleResize
  5. 阅读 events/dispatcher resize 优先级

常见误区

注意

earlyInput 捕获与 Ink readable 互斥,raw mode 前必须 stopCapturingEarlyInput

注意

flushIncomplete 在 readableLength>0 时 re-arm,避免丢鼠标序列后半段

注意

Windows 无 SIGSTOP,SUPPORTS_SUSPEND 为 false

StdinContext:raw mode 与查询器

StdinContext.ts 向整棵 React 树提供:

字段用途
stdin默认 process.stdin,可 render 时覆盖
setRawMode引用计数式开关,禁止直接用 stdin.setRawMode
isRawModeSupportedTTY 且支持 setRawMode
internal_eventEmitteruseInput 订阅的 input 事件源
internal_exitOnCtrlCfalse 时 Ctrl+C 交给应用处理
internal_querierTerminalQuerier,发 DECRQM/XTVERSION 等

Ink 的 <App> 在 render() 时注入真实 stdin/stdout。测试替身可省略 onStdinResume 等可选回调。

源码引用: src/ink/components/StdinContext.ts · 第 5–49 行(共 50 行)

   5| export type Props = {
   6|   /**
   7|    * Stdin stream passed to `render()` in `options.stdin` or `process.stdin` by default. Useful if your app needs to handle user input.
   8|    */
   9|   readonly stdin: NodeJS.ReadStream
  10| 
  11|   /**
  12|    * Ink exposes this function via own `<StdinContext>` to be able to handle Ctrl+C, that's why you should use Ink's `setRawMode` instead of `process.stdin.setRawMode`.
  13|    * If the `stdin` stream passed to Ink does not support setRawMode, this function does nothing.
  14|    */
  15|   readonly setRawMode: (value: boolean) => void
  16| 
  17|   /**
  18|    * A boolean flag determining if the current `stdin` supports `setRawMode`. A component using `setRawMode` might want to use `isRawModeSupported` to nicely fall back in environments where raw mode is not supported.
  19|    */
  20|   readonly isRawModeSupported: boolean
  21| 
  22|   readonly internal_exitOnCtrlC: boolean
  23| 
  24|   readonly internal_eventEmitter: EventEmitter
  25| 
  26|   /** Query the terminal and await responses (DECRQM, OSC 11, etc.).
  27|    *  Null only in the never-reached default context value. */
  28|   readonly internal_querier: TerminalQuerier | null
  29| }
  30| 
  31| /**
  32|  * `StdinContext` is a React context, which exposes input stream.
  33|  */
  34| 
  35| const StdinContext = createContext<Props>({
  36|   stdin: process.stdin,
  37| 
  38|   internal_eventEmitter: new EventEmitter(),
  39|   setRawMode() {},
  40|   isRawModeSupported: false,
  41| 
  42|   internal_exitOnCtrlC: true,
  43|   internal_querier: null,
  44| })
  45| 
  46| // eslint-disable-next-line custom-rules/no-top-level-side-effects
  47| StdinContext.displayName = 'InternalStdinContext'
  48| 
  49| export default StdinContext

App.tsx:启用 raw mode 的副作用

首次 setRawMode(true)(rawModeEnabledCount 0→1)时 App 顺序执行:

  1. stopCapturingEarlyInput() — 与 bootstrap 早期 stdin 捕获互斥
  2. stdin.ref() + setRawMode(true) + readable → handleReadable
  3. EBP bracketed paste、EFE 焦点报告
  4. 若 supportsExtendedKeys():ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS
  5. setImmediate 发 XTVERSION 查询,结果 setXtversionName(SSH 友好)

关闭时逆序:DISABLE 键协议、DFE、DBP、removeListener、unref。多个 useInput 共享计数,最后一个关闭才恢复 cooked mode。

useLayoutEffect vs useEffect: use-input 用 useLayoutEffect 启 raw mode,保证 commit 后同 tick 内终端不再回显按键。

源码引用: src/ink/components/App.tsx · 第 209–280 行(共 778 行)

 209|               setRawMode: this.handleSetRawMode,
 210|               isRawModeSupported: this.isRawModeSupported(),
 211| 
 212|               internal_exitOnCtrlC: this.props.exitOnCtrlC,
 213| 
 214|               internal_eventEmitter: this.internal_eventEmitter,
 215|               internal_querier: this.querier,
 216|             }}
 217|           >
 218|             <TerminalFocusProvider>
 219|               <ClockProvider>
 220|                 <CursorDeclarationContext.Provider
 221|                   value={this.props.onCursorDeclaration ?? (() => {})}
 222|                 >
 223|                   {this.state.error ? (
 224|                     <ErrorOverview error={this.state.error as Error} />
 225|                   ) : (
 226|                     this.props.children
 227|                   )}
 228|                 </CursorDeclarationContext.Provider>
 229|               </ClockProvider>
 230|             </TerminalFocusProvider>
 231|           </StdinContext.Provider>
 232|         </AppContext.Provider>
 233|       </TerminalSizeContext.Provider>
 234|     )
 235|   }
 236| 
 237|   override componentDidMount() {
 238|     // In accessibility mode, keep the native cursor visible for screen magnifiers and other tools
 239|     if (
 240|       this.props.stdout.isTTY &&
 241|       !isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)
 242|     ) {
 243|       this.props.stdout.write(HIDE_CURSOR)
 244|     }
 245|   }
 246| 
 247|   override componentWillUnmount() {
 248|     if (this.props.stdout.isTTY) {
 249|       this.props.stdout.write(SHOW_CURSOR)
 250|     }
 251| 
 252|     // Clear any pending timers
 253|     if (this.incompleteEscapeTimer) {
 254|       clearTimeout(this.incompleteEscapeTimer)
 255|       this.incompleteEscapeTimer = null
 256|     }
 257|     if (this.pendingHyperlinkTimer) {
 258|       clearTimeout(this.pendingHyperlinkTimer)
 259|       this.pendingHyperlinkTimer = null
 260|     }
 261|     // ignore calling setRawMode on an handle stdin it cannot be called
 262|     if (this.isRawModeSupported()) {
 263|       this.handleSetRawMode(false)
 264|     }
 265|   }
 266| 
 267|   override componentDidCatch(error: Error) {
 268|     this.handleExit(error)
 269|   }
 270| 
 271|   handleSetRawMode = (isEnabled: boolean): void => {
 272|     const { stdin } = this.props
 273| 
 274|     if (!this.isRawModeSupported()) {
 275|       if (stdin === process.stdin) {
 276|         throw new Error(
 277|           'Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported',
 278|         )
 279|       } else {
 280|         throw new Error(

源码引用: src/ink/hooks/use-input.ts · 第 42–90 行(共 93 行)

  42| const useInput = (inputHandler: Handler, options: Options = {}) => {
  43|   const { setRawMode, internal_exitOnCtrlC, internal_eventEmitter } = useStdin()
  44| 
  45|   // useLayoutEffect (not useEffect) so that raw mode is enabled synchronously
  46|   // during React's commit phase, before render() returns. With useEffect, raw
  47|   // mode setup is deferred to the next event loop tick via React's scheduler,
  48|   // leaving the terminal in cooked mode — keystrokes echo and the cursor is
  49|   // visible until the effect fires.
  50|   useLayoutEffect(() => {
  51|     if (options.isActive === false) {
  52|       return
  53|     }
  54| 
  55|     setRawMode(true)
  56| 
  57|     return () => {
  58|       setRawMode(false)
  59|     }
  60|   }, [options.isActive, setRawMode])
  61| 
  62|   // Register the listener once on mount so its slot in the EventEmitter's
  63|   // listener array is stable. If isActive were in the effect's deps, the
  64|   // listener would re-append on false→true, moving it behind listeners
  65|   // that registered while it was inactive — breaking
  66|   // stopImmediatePropagation() ordering. useEventCallback keeps the
  67|   // reference stable while reading latest isActive/inputHandler from
  68|   // closure (it syncs via useLayoutEffect, so it's compiler-safe).
  69|   const handleData = useEventCallback((event: InputEvent) => {
  70|     if (options.isActive === false) {
  71|       return
  72|     }
  73|     const { input, key } = event
  74| 
  75|     // If app is not supposed to exit on Ctrl+C, then let input listener handle it
  76|     // Note: discreteUpdates is called at the App level when emitting events,
  77|     // so all listeners are already within a high-priority update context.
  78|     if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) {
  79|       inputHandler(input, key, event)
  80|     }
  81|   })
  82| 
  83|   useEffect(() => {
  84|     internal_eventEmitter?.on('input', handleData)
  85| 
  86|     return () => {
  87|       internal_eventEmitter?.removeListener('input', handleData)
  88|     }
  89|   }, [internal_eventEmitter, handleData])
  90| }

handleReadable 与 processInput

handleReadable 在 read 循环前检测 STDIN_RESUME_GAP_MS(5s):

  • 超过间隔则调用 onStdinResume → Ink reassertTerminalModes(alt-screen、鼠标、扩展键)
  • 应对 tmux attach、SSH 重连、笔记本唤醒后终端 DEC 模式被重置

processInput 调用 parseMultipleKeypresses,若有 key 则 reconciler.discreteUpdates(processKeysInBatch) 一次 处理整批(粘贴/长按)。

incomplete escape: 半个 CSI/鼠标序列会设 timer;fullscreen 下若 readableLength>0 则推迟 flush(timer 先于 poll 触发的问题)。IN_PASTE 用 500ms 超时。

Bun 下 readable 抛错会 logError,避免未捕获异常杀死进程。

源码引用: src/ink/components/App.tsx · 第 282–348 行(共 778 行)

 282|         )
 283|       }
 284|     }
 285| 
 286|     stdin.setEncoding('utf8')
 287| 
 288|     if (isEnabled) {
 289|       // Ensure raw mode is enabled only once
 290|       if (this.rawModeEnabledCount === 0) {
 291|         // Stop early input capture right before we add our own readable handler.
 292|         // Both use the same stdin 'readable' + read() pattern, so they can't
 293|         // coexist -- our handler would drain stdin before Ink's can see it.
 294|         // The buffered text is preserved for REPL.tsx via consumeEarlyInput().
 295|         stopCapturingEarlyInput()
 296|         stdin.ref()
 297|         stdin.setRawMode(true)
 298|         stdin.addListener('readable', this.handleReadable)
 299|         // Enable bracketed paste mode
 300|         this.props.stdout.write(EBP)
 301|         // Enable terminal focus reporting (DECSET 1004)
 302|         this.props.stdout.write(EFE)
 303|         // Enable extended key reporting so ctrl+shift+<letter> is
 304|         // distinguishable from ctrl+<letter>. We write both the kitty stack
 305|         // push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) —
 306|         // terminals honor whichever they implement (tmux only accepts the
 307|         // latter).
 308|         if (supportsExtendedKeys()) {
 309|           this.props.stdout.write(ENABLE_KITTY_KEYBOARD)
 310|           this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS)
 311|         }
 312|         // Probe terminal identity. XTVERSION survives SSH (query/reply goes
 313|         // through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base
 314|         // detection when env vars are absent. Fire-and-forget: the DA1
 315|         // sentinel bounds the round-trip, and if the terminal ignores the
 316|         // query, flush() still resolves and name stays undefined.
 317|         // Deferred to next tick so it fires AFTER the current synchronous
 318|         // init sequence completes — avoids interleaving with alt-screen/mouse
 319|         // tracking enable writes that may happen in the same render cycle.
 320|         setImmediate(() => {
 321|           void Promise.all([
 322|             this.querier.send(xtversion()),
 323|             this.querier.flush(),
 324|           ]).then(([r]) => {
 325|             if (r) {
 326|               setXtversionName(r.name)
 327|               logForDebugging(`XTVERSION: terminal identified as "${r.name}"`)
 328|             } else {
 329|               logForDebugging('XTVERSION: no reply (terminal ignored query)')
 330|             }
 331|           })
 332|         })
 333|       }
 334| 
 335|       this.rawModeEnabledCount++
 336|       return
 337|     }
 338| 
 339|     // Disable raw mode only when no components left that are using it
 340|     if (--this.rawModeEnabledCount === 0) {
 341|       this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS)
 342|       this.props.stdout.write(DISABLE_KITTY_KEYBOARD)
 343|       // Disable terminal focus reporting (DECSET 1004)
 344|       this.props.stdout.write(DFE)
 345|       // Disable bracketed paste mode
 346|       this.props.stdout.write(DBP)
 347|       stdin.setRawMode(false)
 348|       stdin.removeListener('readable', this.handleReadable)

源码引用: src/ink/components/App.tsx · 第 30–35 行(共 778 行)

  30| import {
  31|   getTerminalFocused,
  32|   setTerminalFocused,
  33| } from '../terminal-focus-state.js'
  34| import { TerminalQuerier, xtversion } from '../terminal-querier.js'
  35| import {

parse-keypress:序列识别

parse-keypress.ts 用 termio tokenizer 切分字节流,再匹配:

  • META / FN / CSI u / modifyOtherKeys — 现代终端与 tmux 的 Shift+Enter 等
  • PASTE_START / PASTE_END — bracketed paste 块
  • DECRPM / DA1 / DA2 / Kitty flags / DECXCPR — 查询响应路由到 querier
  • XTVERSION_RE — DCS 终端名
  • SGR_MOUSE_RE — 按下/释放/滚轮/拖拽(button 64/65 滚轮)

createPasteKey 生成 isPasted: true 的 ParsedKey,上层可把整段文本当作一次编辑。

终端响应与按键共用同一 stdin 泵,因此 parser 必须优先识别响应模式,避免把 ESC[?... 误当方向键。

源码引用: src/ink/parse-keypress.ts · 第 1–80 行(共 802 行)

   1| /**
   2|  * Keyboard input parser - converts terminal input to key events
   3|  *
   4|  * Uses the termio tokenizer for escape sequence boundary detection,
   5|  * then interprets sequences as keypresses.
   6|  */
   7| import { Buffer } from 'buffer'
   8| import { PASTE_END, PASTE_START } from './termio/csi.js'
   9| import { createTokenizer, type Tokenizer } from './termio/tokenize.js'
  10| 
  11| // eslint-disable-next-line no-control-regex
  12| const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/
  13| 
  14| // eslint-disable-next-line no-control-regex
  15| const FN_KEY_RE =
  16|   // eslint-disable-next-line no-control-regex
  17|   /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/
  18| 
  19| // CSI u (kitty keyboard protocol): ESC [ codepoint [; modifier] u
  20| // Example: ESC[13;2u = Shift+Enter, ESC[27u = Escape (no modifiers)
  21| // Modifier is optional - when absent, defaults to 1 (no modifiers)
  22| // eslint-disable-next-line no-control-regex
  23| const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/
  24| 
  25| // xterm modifyOtherKeys: ESC [ 27 ; modifier ; keycode ~
  26| // Example: ESC[27;2;13~ = Shift+Enter. Emitted by Ghostty/tmux/xterm when
  27| // modifyOtherKeys=2 is active or via user keybinds, typically over SSH where
  28| // TERM sniffing misses Ghostty and we never push Kitty keyboard mode.
  29| // Note param order is reversed vs CSI u (modifier first, keycode second).
  30| // eslint-disable-next-line no-control-regex
  31| const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/
  32| 
  33| // -- Terminal response patterns (inbound sequences from the terminal itself) --
  34| // DECRPM: CSI ? Ps ; Pm $ y  — response to DECRQM (request mode)
  35| // eslint-disable-next-line no-control-regex
  36| const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/
  37| // DA1: CSI ? Ps ; ... c  — primary device attributes response
  38| // eslint-disable-next-line no-control-regex
  39| const DA1_RE = /^\x1b\[\?([\d;]*)c$/
  40| // DA2: CSI > Ps ; ... c  — secondary device attributes response
  41| // eslint-disable-next-line no-control-regex
  42| const DA2_RE = /^\x1b\[>([\d;]*)c$/
  43| // Kitty keyboard flags: CSI ? flags u  — response to CSI ? u query
  44| // (private ? marker distinguishes from CSI u key events)
  45| // eslint-disable-next-line no-control-regex
  46| const KITTY_FLAGS_RE = /^\x1b\[\?(\d+)u$/
  47| // DECXCPR cursor position: CSI ? row ; col R
  48| // The ? marker disambiguates from modified F3 keys (Shift+F3 = CSI 1;2 R,
  49| // Ctrl+F3 = CSI 1;5 R, etc.) — plain CSI row;col R is genuinely ambiguous.
  50| // eslint-disable-next-line no-control-regex
  51| const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/
  52| // OSC response: OSC code ; data (BEL|ST)
  53| // eslint-disable-next-line no-control-regex
  54| const OSC_RESPONSE_RE = /^\x1b\](\d+);(.*?)(?:\x07|\x1b\\)$/s
  55| // XTVERSION: DCS > | name ST  — terminal name/version string (answer to CSI > 0 q).
  56| // xterm.js replies "xterm.js(X.Y.Z)"; Ghostty, kitty, iTerm2, etc. reply with
  57| // their own name. Unlike TERM_PROGRAM, this survives SSH since the query/reply
  58| // goes through the pty, not the environment.
  59| // eslint-disable-next-line no-control-regex
  60| const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
  61| // SGR mouse event: CSI < button ; col ; row M (press) or m (release)
  62| // Button codes: 64=wheel-up, 65=wheel-down (0x40 | wheel-bit).
  63| // Button 32=left-drag (0x20 | motion-bit). Plain 0/1/2 = left/mid/right click.
  64| // eslint-disable-next-line no-control-regex
  65| const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
  66| 
  67| function createPasteKey(content: string): ParsedKey {
  68|   return {
  69|     kind: 'key',
  70|     name: '',
  71|     fn: false,
  72|     ctrl: false,
  73|     meta: false,
  74|     shift: false,
  75|     option: false,
  76|     super: false,
  77|     sequence: content,
  78|     raw: content,
  79|     isPasted: true,
  80|   }

源码引用: src/ink/parse-keypress.ts · 第 67–80 行(共 802 行)

  67| function createPasteKey(content: string): ParsedKey {
  68|   return {
  69|     kind: 'key',
  70|     name: '',
  71|     fn: false,
  72|     ctrl: false,
  73|     meta: false,
  74|     shift: false,
  75|     option: false,
  76|     super: false,
  77|     sequence: content,
  78|     raw: content,
  79|     isPasted: true,
  80|   }

InputEvent 与 useInput 契约

InputEvent(events/input-event.ts)包装 input 字符串与 Key 对象(leftArrow、ctrl、meta、isPasted 等)。

useInput 行为摘要:

  • isActive: false 时不启 raw mode、handler 早退
  • listener 只在 mount 注册一次,useEventCallback 读最新 handler/isActive,保持 stopImmediatePropagation 顺序稳定
  • Ctrl+C:若 internal_exitOnCtrlC 为 true,不调用 handler(Ink 处理退出)

多 hook 并存: 每个 useInput 都 setRawMode(true),靠 App 计数;inactive hook 仍占位 listener 但立即 return。

REPL 的 PromptInput、全局快捷键、搜索框可能各有一个 useInput,设计快捷键时注意 isActive 互斥。

源码引用: src/ink/hooks/use-input.ts · 第 18–40 行(共 93 行)

  18| /**
  19|  * This hook is used for handling user input.
  20|  * It's a more convenient alternative to using `StdinContext` and listening to `data` events.
  21|  * The callback you pass to `useInput` is called for each character when user enters any input.
  22|  * However, if user pastes text and it's more than one character, the callback will be called only once and the whole string will be passed as `input`.
  23|  *
  24|  * ```
  25|  * import {useInput} from 'ink';
  26|  *
  27|  * const UserInput = () =&gt; {
  28|  *   useInput((input, key) =&gt; {
  29|  *     if (input === 'q') {
  30|  *       // Exit program
  31|  *     }
  32|  *
  33|  *     if (key.leftArrow) {
  34|  *       // Left arrow key pressed
  35|  *     }
  36|  *   });
  37|  *
  38|  *   return …
  39|  * };
  40|  * ```

源码引用: src/ink/hooks/use-input.ts · 第 62–89 行(共 93 行)

  62|   // Register the listener once on mount so its slot in the EventEmitter's
  63|   // listener array is stable. If isActive were in the effect's deps, the
  64|   // listener would re-append on false→true, moving it behind listeners
  65|   // that registered while it was inactive — breaking
  66|   // stopImmediatePropagation() ordering. useEventCallback keeps the
  67|   // reference stable while reading latest isActive/inputHandler from
  68|   // closure (it syncs via useLayoutEffect, so it's compiler-safe).
  69|   const handleData = useEventCallback((event: InputEvent) => {
  70|     if (options.isActive === false) {
  71|       return
  72|     }
  73|     const { input, key } = event
  74| 
  75|     // If app is not supposed to exit on Ctrl+C, then let input listener handle it
  76|     // Note: discreteUpdates is called at the App level when emitting events,
  77|     // so all listeners are already within a high-priority update context.
  78|     if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) {
  79|       inputHandler(input, key, event)
  80|     }
  81|   })
  82| 
  83|   useEffect(() => {
  84|     internal_eventEmitter?.on('input', handleData)
  85| 
  86|     return () => {
  87|       internal_eventEmitter?.removeListener('input', handleData)
  88|     }
  89|   }, [internal_eventEmitter, handleData])

resize:SIGWINCH 到 React 上下文

Ink 构造时 stdout.on('resize', handleResize):

  1. 读 columns/rows,若与缓存相同则 return(抑制双事件)
  2. 更新 terminalColumns/Rows、altScreenParkPatch、Yoga 根 setWidth + calculateLayout
  3. 设置 needsEraseBeforePaint(alt-screen 下一帧 ERASE+HOME 在原子写内)
  4. 同步 render(currentNode) — 不用 scheduleRender

TerminalSizeContext(components/TerminalSizeContext.tsx)把尺寸传给子树,布局组件应用 useTerminalSize(hooks 在 src/hooks,非 ink/hooks)。

ResizeEvent 类为空标记;Dispatcher 对 resize 使用较低优先级连续事件,供 ScrollBox 等订阅 onResize。

源码引用: src/ink/ink.tsx · 第 308–328 行(共 2006 行)

 308|       this.unsubscribeTTYHandlers = () => {
 309|         options.stdout.off('resize', this.handleResize)
 310|         process.off('SIGCONT', this.handleResume)
 311|       }
 312|     }
 313| 
 314|     this.rootNode = dom.createNode('ink-root')
 315|     this.focusManager = new FocusManager((target, event) =>
 316|       dispatcher.dispatchDiscrete(target, event),
 317|     )
 318|     this.rootNode.focusManager = this.focusManager
 319|     this.renderer = createRenderer(this.rootNode, this.stylePool)
 320|     this.rootNode.onRender = this.scheduleRender
 321|     this.rootNode.onImmediateRender = this.onRender
 322|     this.rootNode.onComputeLayout = () => {
 323|       // Calculate layout during React's commit phase so useLayoutEffect hooks
 324|       // have access to fresh layout data
 325|       // Guard against accessing freed Yoga nodes after unmount
 326|       if (this.isUnmounted) {
 327|         return
 328|       }

源码引用: src/ink/events/resize-event.ts · 第 1–1 行(共 2 行)

   1| export class ResizeEvent {}

源码引用: src/ink/events/event-handlers.ts · 第 50–54 行(共 74 行)

  50|   blur: { bubble: 'onBlur', capture: 'onBlurCapture' },
  51|   paste: { bubble: 'onPaste', capture: 'onPasteCapture' },
  52|   resize: { bubble: 'onResize' },
  53|   click: { bubble: 'onClick' },
  54| }

Dispatcher:DOM 风格事件

events/dispatcher.ts 实现与 react-dom 类似的三段收集:

  • 从 target 向上走 parentNode
  • capture handler unshift → 根先执行
  • bubble handler push → 叶先执行(target 自身两段都有)

dispatchEvent 根据 event 类型选 Discrete / Continuous / Default 优先级(react-reconciler constants)。resize、scroll、mouse move 归为 continuous,降低与按键离散更新的穿插。

stopPropagation / stopImmediatePropagation 在 listener 循环中检查。Box 的 onClick 仅在 AlternateScreen 开启鼠标跟踪后由 Ink hit-test 触发。

源码引用: src/ink/events/dispatcher.ts · 第 36–79 行(共 234 行)

  36| /**
  37|  * Collect all listeners for an event in dispatch order.
  38|  *
  39|  * Uses react-dom's two-phase accumulation pattern:
  40|  * - Walk from target to root
  41|  * - Capture handlers are prepended (unshift) → root-first
  42|  * - Bubble handlers are appended (push) → target-first
  43|  *
  44|  * Result: [root-cap, ..., parent-cap, target-cap, target-bub, parent-bub, ..., root-bub]
  45|  */
  46| function collectListeners(
  47|   target: EventTarget,
  48|   event: TerminalEvent,
  49| ): DispatchListener[] {
  50|   const listeners: DispatchListener[] = []
  51| 
  52|   let node: EventTarget | undefined = target
  53|   while (node) {
  54|     const isTarget = node === target
  55| 
  56|     const captureHandler = getHandler(node, event.type, true)
  57|     const bubbleHandler = getHandler(node, event.type, false)
  58| 
  59|     if (captureHandler) {
  60|       listeners.unshift({
  61|         node,
  62|         handler: captureHandler,
  63|         phase: isTarget ? 'at_target' : 'capturing',
  64|       })
  65|     }
  66| 
  67|     if (bubbleHandler && (event.bubbles || isTarget)) {
  68|       listeners.push({
  69|         node,
  70|         handler: bubbleHandler,
  71|         phase: isTarget ? 'at_target' : 'bubbling',
  72|       })
  73|     }
  74| 
  75|     node = node.parentNode
  76|   }
  77| 
  78|   return listeners
  79| }

源码引用: src/ink/events/dispatcher.ts · 第 125–135 行(共 234 行)

 125|     case 'keyup':
 126|     case 'click':
 127|     case 'focus':
 128|     case 'blur':
 129|     case 'paste':
 130|       return DiscreteEventPriority as number
 131|     case 'resize':
 132|     case 'scroll':
 133|     case 'mousemove':
 134|       return ContinuousEventPriority as number
 135|     default:

终端焦点与粘贴模式

TerminalFocusEvent 与 DECSET 1004 焦点 in/out 序列对应;terminal-focus-state.ts 维护全局聚焦标志,use-terminal-focus hook 暴露给 UI(标题栏样式、暂停输入等)。

Bracketed paste: 粘贴内容带边界,解析器不会把粘贴里的 ESC 当独立序列。关闭 raw mode 必须 DBP,否则终端仍发 bracket 包裹文本。

SIGCONT: ink 监听进程恢复,log-update reset previousOutput,并 reassert 模式(与 resize/unmount 共用逻辑)。

Ctrl+C / Ctrl+Z: exitOnCtrlC 控制;Unix 上 Ctrl+Z 可 SIGSTOP(SUPPORTS_SUSPEND)。

源码引用: src/ink/events/terminal-focus-event.ts · 第 1–5 行(共 20 行)

   1| import { Event } from './event.js'
   2| 
   3| export type TerminalFocusEventType = 'terminalfocus' | 'terminalblur'
   4| 
   5| /**

源码引用: src/ink/hooks/use-terminal-focus.ts · 第 1–17 行(共 17 行)

   1| import { useContext } from 'react'
   2| import TerminalFocusContext from '../components/TerminalFocusContext.js'
   3| 
   4| /**
   5|  * Hook to check if the terminal has focus.
   6|  *
   7|  * Uses DECSET 1004 focus reporting - the terminal sends escape sequences
   8|  * when it gains or loses focus. These are handled automatically
   9|  * by Ink and filtered from useInput.
  10|  *
  11|  * @returns true if the terminal is focused (or focus state is unknown)
  12|  */
  13| export function useTerminalFocus(): boolean {
  14|   const { isTerminalFocused } = useContext(TerminalFocusContext)
  15|   return isTerminalFocused
  16| }
  17| 

与 REPL 的接缝

REPL 不直接读 stdin;依赖 Ink App 泵。相关接缝:

  • consumeEarlyInput:raw mode 前 bootstrap 缓冲的预输入
  • onStdinResume:全屏下重进 alt-screen + 鼠标跟踪
  • dispatchKeyboardEvent:与 useInput 并行,服务 Box 树
  • selection 鼠标:App 收 SGR 鼠标 → Ink selection 状态 → onSelectionChange → scheduleRender

调试「按键无反应」:确认 isRawModeSupported、是否有 inactive useInput 抢焦点、tmux 是否剥离 MODIFY_OTHER_KEYS。

调试「粘贴变乱码」:查 EBP 是否启用、parser 是否停在 IN_PASTE、PASTE_TIMEOUT 是否过短。

源码目录

核心链:App.tsx → parse-keypress.ts → events/* → hooks/use-input.ts。resize 另见 ink.tsx handleResize。

本章小结与延伸

终端事件层保证字节流正确语义化并恢复终端模式。业务快捷键应优先 useInput;需要冒泡时用 Box onKeyDown。 继续学习:

  • Ink Hooks
  • 渲染管线
Prev
Ink 渲染管线 · Screen 与终端输出
Next
Ink Hooks · 输入、搜索、终端状态