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

本章总览

src/ink/components/ 提供终端 UI 的布局与交互原语(18 文件)。Box 类似 flex div;Text 负责换行/截断与样式;ScrollBox 是可滚动视口 + imperative API;AlternateScreen 切换 xterm 1049 备用屏并启用鼠标跟踪。Claude Code 的 Message、PromptInput、FullscreenLayout 都建立在这些原语之上。本章说明各组件的 DOM 映射、事件面与 REPL 全屏布局中的典型组合。

学完本章你应该能

  • 说明 Box 的 tabIndex、autoFocus、鼠标与键盘事件属性
  • 理解 Text 的 textWrap 模式与 bold/dim 互斥
  • 掌握 ScrollBox handle 与 stickyScroll、虚拟列表的配合
  • 解释 AlternateScreen 与 Ink altScreenActive 标志的同步
  • 知道 Context 组件(Stdin、TerminalSize、App)的注入点

核心概念(先读懂这些)

Box 是事件目标节点

只有 Box(及继承布局的 ScrollBox)参与 hit-test、焦点环、onClick/onKeyDown 冒泡。Text 是叶子,不接收点击。AlternateScreen 开启后鼠标事件才生效。

ScrollBox = Box + 视口裁剪 + 滚动状态机

子节点在 Yoga 中仍占全高,渲染时只光栅化 scrollTop..scrollTop+height 交集。stickyScroll 在内容增高时保持贴底;虚拟列表通过 setClampBounds 限制 scrollTop 不滚进未挂载空白。

AlternateScreen 门控副作用

进入时写 ENTER_ALT_SCREEN、ENABLE_MOUSE_TRACKING;退出时逆序。TerminalWriteProvider 的 writeRaw 必须稳定引用,避免 resize 导致 useLayoutEffect 依赖变化而闪烁进出 alt-screen。

建议学习步骤

  1. 阅读 Box Props 与 reconciler 事件映射
  2. 阅读 Text 的 memoizedStylesForWrap
  3. 阅读 ScrollBox handle 方法列表
  4. 阅读 AlternateScreen 的 mount/unmount 序列
  5. 对照 REPL FullscreenLayout 组合方式

常见误区

注意

onClick 在非 AlternateScreen 下为 no-op

注意

ScrollBox 需约束高度父级,否则 Yoga 高度无限无法滚动

注意

RawAnsi 绕过 Text 测量,错误宽度会破坏布局

组件全景与 Context

18 个文件可分为 布局叶、功能壳、Context 三类:

布局/交互: Box、Text、Newline、Spacer、ScrollBox、Link、Button、RawAnsi、Ansi(再导出)、NoSelect

功能壳: App(根)、AlternateScreen、ErrorOverview

Context: AppContext、StdinContext、TerminalSizeContext、TerminalFocusContext、ClockContext、CursorDeclarationContext

render() 时 Ink 包裹:

<App>
  <TerminalWriteProvider>
    {userTree}
  </TerminalWriteProvider>
</App>

用户代码通常再包 &lt;AlternateScreen&gt;&lt;FullscreenLayout&gt;...&lt;/FullscreenLayout&gt;&lt;/AlternateScreen&gt;。

Box:Flex 与焦点

Box 接受 Styles(flexDirection、padding、border 等)并映射到 Yoga:

  • tabIndex ≥0 参与 Tab 循环;-1 仅程序 focus
  • autoFocus → commitMount 时 FocusManager.focus
  • onClick → hit-test 命中最深 Box 后冒泡;stopImmediatePropagation 阻止上层
  • onMouseEnter/Leave → 不冒泡(类 DOM mouseenter)
  • onKeyDown/Capture、onFocus/Blur → Dispatcher 分发

React Compiler 缓存 _c(42) 分解 props,阅读时对照 flex* 与 style 对象。

Box 是 ScrollBox 的内部实现基座(ScrollBox 渲染为带 overflow:scroll 的 Box 语义)。

源码引用: src/ink/components/Box.tsx · 第 11–50 行(共 120 行)

  11| export type Props = Except<Styles, 'textWrap'> & {
  12|   ref?: Ref<DOMElement>
  13|   /**
  14|    * Tab order index. Nodes with `tabIndex >= 0` participate in
  15|    * Tab/Shift+Tab cycling; `-1` means programmatically focusable only.
  16|    */
  17|   tabIndex?: number
  18|   /**
  19|    * Focus this element when it mounts. Like the HTML `autofocus`
  20|    * attribute — the FocusManager calls `focus(node)` during the
  21|    * reconciler's `commitMount` phase.
  22|    */
  23|   autoFocus?: boolean
  24|   /**
  25|    * Fired on left-button click (press + release without drag). Only works
  26|    * inside `<AlternateScreen>` where mouse tracking is enabled — no-op
  27|    * otherwise. The event bubbles from the deepest hit Box up through
  28|    * ancestors; call `event.stopImmediatePropagation()` to stop bubbling.
  29|    */
  30|   onClick?: (event: ClickEvent) => void
  31|   onFocus?: (event: FocusEvent) => void
  32|   onFocusCapture?: (event: FocusEvent) => void
  33|   onBlur?: (event: FocusEvent) => void
  34|   onBlurCapture?: (event: FocusEvent) => void
  35|   onKeyDown?: (event: KeyboardEvent) => void
  36|   onKeyDownCapture?: (event: KeyboardEvent) => void
  37|   /**
  38|    * Fired when the mouse moves into this Box's rendered rect. Like DOM
  39|    * `mouseenter`, does NOT bubble — moving between children does not
  40|    * re-fire on the parent. Only works inside `<AlternateScreen>` where
  41|    * mode-1003 mouse tracking is enabled.
  42|    */
  43|   onMouseEnter?: () => void
  44|   /** Fired when the mouse moves out of this Box's rendered rect. */
  45|   onMouseLeave?: () => void
  46| }
  47| 
  48| /**
  49|  * `<Box>` is an essential Ink component to build your layout. It's like `<div style="display: flex">` in the browser.
  50|  */

源码引用: src/ink/components/Box.tsx · 第 48–55 行(共 120 行)

  48| /**
  49|  * `<Box>` is an essential Ink component to build your layout. It's like `<div style="display: flex">` in the browser.
  50|  */
  51| function Box({
  52|   children,
  53|   flexWrap = 'nowrap',
  54|   flexDirection = 'row',
  55|   flexGrow = 0,

Text:换行与样式

Text 将子字符串光栅化为带样式的行:

textWrap行为
wrap / wrap-trim超宽换行,trim 去尾空格
end / middle / truncate-*单行截断,省略号策略不同

bold 与 dim 互斥(终端 SGR 限制),TypeScript 联合类型强制二选一。

颜色通过 colorize.ts 转 ANSI;宽字符宽度 stringWidth + bidi reorderBidi。

嵌套 Text 合并样式;纯文本节点 squash 优化减少 Yoga 子节点数。

Message 组件大量用 &lt;Text wrap="wrap-trim"&gt; 展示 assistant 输出。

源码引用: src/ink/components/Text.tsx · 第 5–42 行(共 145 行)

   5| type BaseProps = {
   6|   /**
   7|    * Change text color. Accepts a raw color value (rgb, hex, ansi).
   8|    */
   9|   readonly color?: Color
  10| 
  11|   /**
  12|    * Same as `color`, but for background.
  13|    */
  14|   readonly backgroundColor?: Color
  15| 
  16|   /**
  17|    * Make the text italic.
  18|    */
  19|   readonly italic?: boolean
  20| 
  21|   /**
  22|    * Make the text underlined.
  23|    */
  24|   readonly underline?: boolean
  25| 
  26|   /**
  27|    * Make the text crossed with a line.
  28|    */
  29|   readonly strikethrough?: boolean
  30| 
  31|   /**
  32|    * Inverse background and foreground colors.
  33|    */
  34|   readonly inverse?: boolean
  35| 
  36|   /**
  37|    * This property tells Ink to wrap or truncate text if its width is larger than container.
  38|    * If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines.
  39|    * If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off.
  40|    */
  41|   readonly wrap?: Styles['textWrap']
  42| 

源码引用: src/ink/components/Text.tsx · 第 60–90 行(共 145 行)

  60|     flexShrink: 1,
  61|     flexDirection: 'row',
  62|     textWrap: 'wrap',
  63|   },
  64|   'wrap-trim': {
  65|     flexGrow: 0,
  66|     flexShrink: 1,
  67|     flexDirection: 'row',
  68|     textWrap: 'wrap-trim',
  69|   },
  70|   end: {
  71|     flexGrow: 0,
  72|     flexShrink: 1,
  73|     flexDirection: 'row',
  74|     textWrap: 'end',
  75|   },
  76|   middle: {
  77|     flexGrow: 0,
  78|     flexShrink: 1,
  79|     flexDirection: 'row',
  80|     textWrap: 'middle',
  81|   },
  82|   'truncate-end': {
  83|     flexGrow: 0,
  84|     flexShrink: 1,
  85|     flexDirection: 'row',
  86|     textWrap: 'truncate-end',
  87|   },
  88|   truncate: {
  89|     flexGrow: 0,
  90|     flexShrink: 1,

ScrollBox:imperative 滚动 API

ScrollBoxHandle 方法(REPL VirtualMessageList 依赖):

  • scrollTo / scrollBy — 用户/程序滚动;打破 stickyScroll
  • scrollToElement(el, offset) — 渲染时读 Yoga 顶坐标,避免 throttle 竞态
  • scrollToBottom — 设 sticky 贴底
  • getFreshScrollHeight — 同步 Yoga 内容高
  • setClampBounds(min,max) — 虚拟列表限制滚动范围,避免滚进未挂载区
  • subscribe — 监听 imperative 滚动(非 renderer sticky 更新)

渲染层 viewport culling 只画可见子树;scrollHint 尝试 DECSTBM 硬件滚。

markScrollActivity(bootstrap/state)在滚动时标记用户活跃,影响自动滚底策略。

源码引用: src/ink/components/ScrollBox.tsx · 第 10–62 行(共 260 行)

  10| import type { DOMElement } from '../dom.js'
  11| import { markDirty, scheduleRenderFrom } from '../dom.js'
  12| import { markCommitStart } from '../reconciler.js'
  13| import type { Styles } from '../styles.js'
  14| import '../global.d.ts'
  15| import Box from './Box.js'
  16| 
  17| export type ScrollBoxHandle = {
  18|   scrollTo: (y: number) => void
  19|   scrollBy: (dy: number) => void
  20|   /**
  21|    * Scroll so `el`'s top is at the viewport top (plus `offset`). Unlike
  22|    * scrollTo which bakes a number that's stale by the time the throttled
  23|    * render fires, this defers the position read to render time —
  24|    * render-node-to-output reads `el.yogaNode.getComputedTop()` in the
  25|    * SAME Yoga pass that computes scrollHeight. Deterministic. One-shot.
  26|    */
  27|   scrollToElement: (el: DOMElement, offset?: number) => void
  28|   scrollToBottom: () => void
  29|   getScrollTop: () => number
  30|   getPendingDelta: () => number
  31|   getScrollHeight: () => number
  32|   /**
  33|    * Like getScrollHeight, but reads Yoga directly instead of the cached
  34|    * value written by render-node-to-output (throttled, up to 16ms stale).
  35|    * Use when you need a fresh value in useLayoutEffect after a React commit
  36|    * that grew content. Slightly more expensive (native Yoga call).
  37|    */
  38|   getFreshScrollHeight: () => number
  39|   getViewportHeight: () => number
  40|   /**
  41|    * Absolute screen-buffer row of the first visible content line (inside
  42|    * padding). Used for drag-to-scroll edge detection.
  43|    */
  44|   getViewportTop: () => number
  45|   /**
  46|    * True when scroll is pinned to the bottom. Set by scrollToBottom, the
  47|    * initial stickyScroll attribute, and by the renderer when positional
  48|    * follow fires (scrollTop at prevMax, content grows). Cleared by
  49|    * scrollTo/scrollBy. Stable signal for "at bottom" that doesn't depend on
  50|    * layout values (unlike scrollTop+viewportH >= scrollHeight).
  51|    */
  52|   isSticky: () => boolean
  53|   /**
  54|    * Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom).
  55|    * Does NOT fire for stickyScroll updates done by the Ink renderer — those
  56|    * happen during Ink's render phase after React has committed. Callers that
  57|    * care about the sticky case should treat "at bottom" as a fallback.
  58|    */
  59|   subscribe: (listener: () => void) => () => void
  60|   /**
  61|    * Set the render-time scrollTop clamp to the currently-mounted children's
  62|    * coverage span. Called by useVirtualScroll after computing its range;

源码引用: src/ink/components/ScrollBox.tsx · 第 72–80 行(共 260 行)

  72|   Styles,
  73|   'textWrap' | 'overflow' | 'overflowX' | 'overflowY'
  74| > & {
  75|   ref?: Ref<ScrollBoxHandle>
  76|   /**
  77|    * When true, automatically pins scroll position to the bottom when content
  78|    * grows. Unset manually via scrollTo/scrollBy to break the stickiness.
  79|    */
  80|   stickyScroll?: boolean

AlternateScreen 与鼠标

AlternateScreen 子树挂载时:

  • Ink altScreenActive = true
  • stdout 写 ENTER_ALT_SCREEN(?1049h)
  • ENABLE_MOUSE_TRACKING(1003 全移动 + 点击)
  • 选择、超链、hover 生效

卸载时 EXIT_ALT_SCREEN、DISABLE_MOUSE_TRACKING;与 ink unmount 的 writeSync 路径重复作为双保险。

NoSelect 标记子树进入 noSelect 位图(gutter、chrome);搜索与选择均跳过。

Link 组件写 OSC 8 hyperlinks,点击由 Ink 延迟打开浏览器(防双击误触)。

源码引用: src/ink/components/AlternateScreen.tsx · 第 1–60 行(共 88 行)

   1| import React, {
   2|   type PropsWithChildren,
   3|   useContext,
   4|   useInsertionEffect,
   5| } from 'react'
   6| import instances from '../instances.js'
   7| import {
   8|   DISABLE_MOUSE_TRACKING,
   9|   ENABLE_MOUSE_TRACKING,
  10|   ENTER_ALT_SCREEN,
  11|   EXIT_ALT_SCREEN,
  12| } from '../termio/dec.js'
  13| import { TerminalWriteContext } from '../useTerminalNotification.js'
  14| import Box from './Box.js'
  15| import { TerminalSizeContext } from './TerminalSizeContext.js'
  16| 
  17| type Props = PropsWithChildren<{
  18|   /** Enable SGR mouse tracking (wheel + click/drag). Default true. */
  19|   mouseTracking?: boolean
  20| }>
  21| 
  22| /**
  23|  * Run children in the terminal's alternate screen buffer, constrained to
  24|  * the viewport height. While mounted:
  25|  *
  26|  * - Enters the alt screen (DEC 1049), clears it, homes the cursor
  27|  * - Constrains its own height to the terminal row count, so overflow must
  28|  *   be handled via `overflow: scroll` / flexbox (no native scrollback)
  29|  * - Optionally enables SGR mouse tracking (wheel + click/drag) — events
  30|  *   surface as `ParsedKey` (wheel) and update the Ink instance's
  31|  *   selection state (click/drag)
  32|  *
  33|  * On unmount, disables mouse tracking and exits the alt screen, restoring
  34|  * the main screen's content. Safe for use in ctrl-o transcript overlays
  35|  * and similar temporary fullscreen views — the main screen is preserved.
  36|  *
  37|  * Notifies the Ink instance via `setAltScreenActive()` so the renderer
  38|  * keeps the cursor inside the viewport (preventing the cursor-restore LF
  39|  * from scrolling content) and so signal-exit cleanup can exit the alt
  40|  * screen if the component's own unmount doesn't run.
  41|  */
  42| export function AlternateScreen({
  43|   children,
  44|   mouseTracking = true,
  45| }: Props): React.ReactNode {
  46|   const size = useContext(TerminalSizeContext)
  47|   const writeRaw = useContext(TerminalWriteContext)
  48| 
  49|   // useInsertionEffect (not useLayoutEffect): react-reconciler calls
  50|   // resetAfterCommit between the mutation and layout commit phases, and
  51|   // Ink's resetAfterCommit triggers onRender. With useLayoutEffect, that
  52|   // first onRender fires BEFORE this effect — writing a full frame to the
  53|   // main screen with altScreen=false. That frame is preserved when we
  54|   // enter alt screen and revealed on exit as a broken view. Insertion
  55|   // effects fire during the mutation phase, before resetAfterCommit, so
  56|   // ENTER_ALT_SCREEN reaches the terminal before the first frame does.
  57|   // Cleanup timing is unchanged: both insertion and layout effect cleanup
  58|   // run in the mutation phase on unmount, before resetAfterCommit.
  59|   useInsertionEffect(() => {
  60|     const ink = instances.get(process.stdout)

源码引用: src/ink/components/NoSelect.tsx · 第 1–30 行(共 46 行)

   1| import React, { type PropsWithChildren } from 'react'
   2| import Box, { type Props as BoxProps } from './Box.js'
   3| 
   4| type Props = Omit<BoxProps, 'noSelect'> & {
   5|   /**
   6|    * Extend the exclusion zone from column 0 to this box's right edge,
   7|    * for every row this box occupies. Use for gutters rendered inside a
   8|    * wider indented container (e.g. a diff inside a tool message row):
   9|    * without this, a multi-row drag picks up the container's leading
  10|    * indent on rows below the prefix.
  11|    *
  12|    * @default false
  13|    */
  14|   fromLeftEdge?: boolean
  15| }
  16| 
  17| /**
  18|  * Marks its contents as non-selectable in fullscreen text selection.
  19|  * Cells inside this box are skipped by both the selection highlight and
  20|  * the copied text — the gutter stays visually unchanged while the user
  21|  * drags, making it clear what will be copied.
  22|  *
  23|  * Use to fence off gutters (line numbers, diff +/- sigils, list bullets)
  24|  * so click-drag over rendered code yields clean pasteable content:
  25|  *
  26|  *   <Box flexDirection="row">
  27|  *     <NoSelect fromLeftEdge><Text dimColor> 42 +</Text></NoSelect>
  28|  *     <Text>const x = 1</Text>
  29|  *   </Box>
  30|  *

App 与 ErrorOverview

App 是 Ink 内部根(见 terminal-events 章):stdin 泵、raw mode、querier、多击选择。

ErrorOverview 在 getDerivedStateFromError 后展示 React 错误边界 UI,避免白屏 ANSI。

业务 REPL 另有上层 ErrorBoundary;Ink 层捕获的是渲染树内同步抛错。

TerminalSizeContext 传递 columns/rows;TerminalFocusProvider 传递焦点状态。

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

  98|   // MULTI_CLICK_TIMEOUT_MS so double-click can cancel it.
  99|   readonly getHyperlinkAt: (col: number, row: number) => string | undefined
 100|   // Open a hyperlink URL in the browser. Called after the timer fires.
 101|   readonly onOpenHyperlink: (url: string) => void
 102|   // Called on double/triple-click PRESS at (col, row). count=2 selects
 103|   // the word under the cursor; count=3 selects the line. Ink reads the
 104|   // screen buffer to find word/line boundaries and mutates selection,
 105|   // setting isDragging=true so a subsequent drag extends by word/line.
 106|   readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void
 107|   // Called on drag-motion. Mode-aware: char mode updates focus to the
 108|   // exact cell; word/line mode snaps to word/line boundaries. Needs
 109|   // screen-buffer access (word boundaries) so lives on Ink, not here.
 110|   readonly onSelectionDrag: (col: number, row: number) => void

源码引用: src/ink/components/ErrorOverview.tsx · 第 1–40 行(共 135 行)

   1| import codeExcerpt, { type CodeExcerpt } from 'code-excerpt'
   2| import { readFileSync } from 'fs'
   3| import React from 'react'
   4| import StackUtils from 'stack-utils'
   5| import Box from './Box.js'
   6| import Text from './Text.js'
   7| 
   8| /* eslint-disable custom-rules/no-process-cwd -- stack trace file:// paths are relative to the real OS cwd, not the virtual cwd */
   9| 
  10| // Error's source file is reported as file:///home/user/file.js
  11| // This function removes the file://[cwd] part
  12| const cleanupPath = (path: string | undefined): string | undefined => {
  13|   return path?.replace(`file://${process.cwd()}/`, '')
  14| }
  15| 
  16| let stackUtils: StackUtils | undefined
  17| function getStackUtils(): StackUtils {
  18|   return (stackUtils ??= new StackUtils({
  19|     cwd: process.cwd(),
  20|     internals: StackUtils.nodeInternals(),
  21|   }))
  22| }
  23| 
  24| /* eslint-enable custom-rules/no-process-cwd */
  25| 
  26| type Props = {
  27|   readonly error: Error
  28| }
  29| 
  30| export default function ErrorOverview({ error }: Props) {
  31|   const stack = error.stack ? error.stack.split('\n').slice(1) : undefined
  32|   const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined
  33|   const filePath = cleanupPath(origin?.file)
  34|   let excerpt: CodeExcerpt[] | undefined
  35|   let lineWidth = 0
  36| 
  37|   if (filePath && origin?.line) {
  38|     try {
  39|       // eslint-disable-next-line custom-rules/no-sync-fs -- sync render path; error overlay can't go async without suspense restructuring
  40|       const sourceCode = readFileSync(filePath, 'utf8')

RawAnsi、Link、Button

RawAnsi 原样写入 ANSI 字符串,不参与 Text 换行测量;用于进度条、外部工具输出。滥用会导致 Yoga 宽度与实际占格不一致。

Link 包装 Text 子节点并注入 hyperlink 元数据,渲染时 OSC 8 包裹。

Button 是可聚焦 Box + 样式预设,支持 onPress;Claude Code 权限弹窗少用,多用于设置页 TUI。

Newline / Spacer 是布局辅助,等价于固定高度/换行 Box。

源码引用: src/ink/components/RawAnsi.tsx · 第 1–40 行(共 40 行)

   1| import React from 'react'
   2| 
   3| type Props = {
   4|   /**
   5|    * Pre-rendered ANSI lines. Each element must be exactly one terminal row
   6|    * (already wrapped to `width` by the producer) with ANSI escape codes inline.
   7|    */
   8|   lines: string[]
   9|   /** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */
  10|   width: number
  11| }
  12| 
  13| /**
  14|  * Bypass the <Ansi> → React tree → Yoga → squash → re-serialize roundtrip for
  15|  * content that is already terminal-ready.
  16|  *
  17|  * Use this when an external renderer (e.g. the ColorDiff NAPI module) has
  18|  * already produced ANSI-escaped, width-wrapped output. A normal <Ansi> mount
  19|  * reparses that output into one React <Text> per style span, lays out each
  20|  * span as a Yoga flex child, then walks the tree to re-emit the same escape
  21|  * codes it was given. For a long transcript full of syntax-highlighted diffs
  22|  * that roundtrip is the dominant cost of the render.
  23|  *
  24|  * This component emits a single Yoga leaf with a constant-time measure func
  25|  * (width × lines.length) and hands the joined string straight to output.write(),
  26|  * which already splits on '\n' and parses ANSI into the screen buffer.
  27|  */
  28| export function RawAnsi({ lines, width }: Props): React.ReactNode {
  29|   if (lines.length === 0) {
  30|     return null
  31|   }
  32|   return (
  33|     <ink-raw-ansi
  34|       rawText={lines.join('\n')}
  35|       rawWidth={width}
  36|       rawHeight={lines.length}
  37|     />
  38|   )
  39| }
  40| 

源码引用: src/ink/components/Link.tsx · 第 1–32 行(共 32 行)

   1| import type { ReactNode } from 'react'
   2| import React from 'react'
   3| import { supportsHyperlinks } from '../supports-hyperlinks.js'
   4| import Text from './Text.js'
   5| 
   6| export type Props = {
   7|   readonly children?: ReactNode
   8|   readonly url: string
   9|   readonly fallback?: ReactNode
  10| }
  11| 
  12| export default function Link({
  13|   children,
  14|   url,
  15|   fallback,
  16| }: Props): React.ReactNode {
  17|   // Use children if provided, otherwise display the URL
  18|   const content = children ?? url
  19| 
  20|   if (supportsHyperlinks()) {
  21|     // Wrap in Text to ensure we're in a text context
  22|     // (ink-link is a text element like ink-text)
  23|     return (
  24|       <Text>
  25|         <ink-link href={url}>{content}</ink-link>
  26|       </Text>
  27|     )
  28|   }
  29| 
  30|   return <Text>{fallback ?? content}</Text>
  31| }
  32| 

REPL 典型布局组合

FullscreenLayout(components 层)大致结构:

AlternateScreen
  └─ Box column (fullscreen)
       ├─ ScrollBox (transcript, stickyScroll)
       │    └─ VirtualMessageList → MessageRow → Text/Box...
       ├─ overlay Box (PermissionRequest)
       └─ bottom Box (PromptInput)
  • transcript 区 ScrollBox + 虚拟化减少 DOM 节点
  • PromptInput 内 Text + use-declared-cursor
  • 搜索高亮 useSearchHighlight 作用于整帧 Screen,与 ScrollBox 滚动偏移联动 rowOffset

改布局高度时,先改 ScrollBox 父 Box 的 flexGrow/fixed height,再测 getFreshScrollHeight。

Ansi 与样式继承

Ansi.tsx 与 colorize.ts 为 Text 子节点提供 256/truecolor SGR。父 Box 的 backgroundColor 不自动继承到 Text,需显式传 props,否则透明背景显示终端默认色。

warn.ts 在开发模式对无效 flex 组合、缺失高度父级等发出一次性警告。全屏 REPL 首次 mount 若见 ScrollBox 警告,检查 FullscreenLayout 是否给 transcript 列 flexGrow 且父链有确定高度。

与 dom.ts 元素类型映射

reconciler 的 ElementNames 把 JSX 类型映射到内部节点:

  • ink-box、ink-text、ink-scrollbox 等
  • 自定义节点实现 LayoutDisplay(block/inline)影响 Yoga

setAttribute 处理 scrollTop、stickyScroll 等非样式属性;markDirty 触发下一帧 onRender。

阅读组件源码时,对照 dom.ts 的 createNode 分支可看到默认样式与 measure 函数挂载点。

源码引用: src/ink/dom.ts · 第 1–60 行(共 485 行)

   1| import type { FocusManager } from './focus.js'
   2| import { createLayoutNode } from './layout/engine.js'
   3| import type { LayoutNode } from './layout/node.js'
   4| import { LayoutDisplay, LayoutMeasureMode } from './layout/node.js'
   5| import measureText from './measure-text.js'
   6| import { addPendingClear, nodeCache } from './node-cache.js'
   7| import squashTextNodes from './squash-text-nodes.js'
   8| import type { Styles, TextStyles } from './styles.js'
   9| import { expandTabs } from './tabstops.js'
  10| import wrapText from './wrap-text.js'
  11| 
  12| type InkNode = {
  13|   parentNode: DOMElement | undefined
  14|   yogaNode?: LayoutNode
  15|   style: Styles
  16| }
  17| 
  18| export type TextName = '#text'
  19| export type ElementNames =
  20|   | 'ink-root'
  21|   | 'ink-box'
  22|   | 'ink-text'
  23|   | 'ink-virtual-text'
  24|   | 'ink-link'
  25|   | 'ink-progress'
  26|   | 'ink-raw-ansi'
  27| 
  28| export type NodeNames = ElementNames | TextName
  29| 
  30| // eslint-disable-next-line @typescript-eslint/naming-convention
  31| export type DOMElement = {
  32|   nodeName: ElementNames
  33|   attributes: Record<string, DOMNodeAttribute>
  34|   childNodes: DOMNode[]
  35|   textStyles?: TextStyles
  36| 
  37|   // Internal properties
  38|   onComputeLayout?: () => void
  39|   onRender?: () => void
  40|   onImmediateRender?: () => void
  41|   // Used to skip empty renders during React 19's effect double-invoke in test mode
  42|   hasRenderedContent?: boolean
  43| 
  44|   // When true, this node needs re-rendering
  45|   dirty: boolean
  46|   // Set by the reconciler's hideInstance/unhideInstance; survives style updates.
  47|   isHidden?: boolean
  48|   // Event handlers set by the reconciler for the capture/bubble dispatcher.
  49|   // Stored separately from attributes so handler identity changes don't
  50|   // mark dirty and defeat the blit optimization.
  51|   _eventHandlers?: Record<string, unknown>
  52| 
  53|   // Scroll state for overflow: 'scroll' boxes. scrollTop is the number of
  54|   // rows the content is scrolled down by. scrollHeight/scrollViewportHeight
  55|   // are computed at render time and stored for imperative access. stickyScroll
  56|   // auto-pins scrollTop to the bottom when content grows.
  57|   scrollTop?: number
  58|   // Accumulated scroll delta not yet applied to scrollTop. The renderer
  59|   // drains this at SCROLL_MAX_PER_FRAME rows/frame so fast flicks show
  60|   // intermediate frames instead of one big jump. Direction reversal

性能与节点规模

REPL transcript 虚拟化后,ScrollBox 子树仅挂载可见 message 的 DOM 节点,但 Yoga 仍对可见子树做布局。避免在单行 message 内嵌套数十层 Box;优先扁平 Text 与少量 Box 分行。

Spacer 仅占用固定行高,不参与文本测量,适合工具栏与 transcript 之间的分隔。ClockProvider 下的定时重绘应局限在小子树(spinner),勿包裹整个 ScrollBox。Permission overlay 使用绝对定位 Box 叠在 ScrollBox 之上,不改变 transcript 布局度量。

源码目录

建议:Box.tsx → Text.tsx → ScrollBox.tsx → AlternateScreen.tsx;对照 screens/REPL 与 components/FullscreenLayout 的 JSX。

本章小结与延伸

业务 UI 应组合 Box/Text/ScrollBox 而非重复实现终端布局。全屏 REPL 几乎总是包在 AlternateScreen 内。 继续学习:

  • 渲染管线
  • Ink Hooks
Prev
Ink Hooks · 输入、搜索、终端状态
Next
useCanUseTool · 权限 UI 接缝