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/hooks/ 共 12 个文件,是 Ink 对 React 层的公开辅助 API(与 src/hooks/ 业务 hooks 不同)。它们薄封装 StdinContext、instances 单例上的 Ink 实例方法,或订阅终端/动画时钟。本章重点讲解 useSearchHighlight(REPL transcript 搜索)、useInput、useStdin、终端焦点/视口/标题,以及 useSelection 与 alt-screen 选择模型的关系。

学完本章你应该能

  • 区分 ink/hooks 与 src/hooks 的职责边界
  • 说明 useSearchHighlight 屏幕空间高亮与 scanElement 的用途
  • 理解 useStdin 如何暴露 setRawMode 与 EventEmitter
  • 掌握 use-terminal-viewport 与 ScrollBox 滚动坐标的换算
  • 知道 use-animation-frame / use-interval 与 ClockContext 的协作

核心概念(先读懂这些)

instances.get(stdout) 单例

render() 时 Ink 注册到 instances Map,key 为 stdout。hooks 通过 process.stdout 取当前实例,调用 setSearchHighlight、scanElementSubtree 等实例方法。无实例时 useSearchHighlight 返回 no-op,避免测试环境抛错。

useSearchHighlight 是屏幕空间 API

setQuery 驱动 applySearchHighlight 反色所有可见匹配;setPositions 叠加当前匹配黄色高亮;scanElement 把 MAIN 树某 DOM 子树光栅化到临时 Screen 再 scanPositions,供 VirtualMessageList 算跳转索引。高亮的是渲染后文本,不是 message JSON。

Stdin hook 与 Context 等价

use-stdin 读 StdinContext;use-input 在此基础上注册 listener。必须位于 App 子树内,否则得到默认 Context(isRawModeSupported: false)。

建议学习步骤

  1. 阅读 use-search-highlight 与 ink.tsx 实例方法
  2. 阅读 use-stdin / use-input 依赖链
  3. 阅读 use-terminal-viewport 与 ScrollBox handle
  4. 阅读 use-selection 与 selection.ts
  5. 浏览 use-tab-status、use-terminal-title 的 OSC 写入

常见误区

注意

useSearchHighlight 必须 useContext(StdinContext) 满足 hook 规则,即使未读字段

注意

scanElement 昂贵,仅在搜索索引构建时调用,勿每帧调用

注意

use-terminal-viewport 依赖 TerminalSizeContext 与 ink 实例尺寸同步

目录与导出关系

12 个 hook 文件一览:

文件作用
use-stdin.ts读 StdinContext
use-input.tsraw mode + input 事件
use-app.tsAppContext(exit、stdout 写)
use-search-highlight.ts搜索反色 + scanElement
use-selection.ts读/写 Ink 选择状态
use-terminal-focus.ts终端窗口聚焦
use-terminal-viewport.ts滚动视口坐标
use-terminal-title.ts窗口/图标标题 OSC
use-tab-status.tsOSC 21337 标签状态点
use-declared-cursor.tsIME 光标声明
use-interval.ts / use-animation-frame.ts时钟驱动重绘

Claude Code 业务层 src/hooks/useTerminalSize.ts 等与 TerminalSizeContext 配合,不要与上表混淆。

useSearchHighlight:三件套 API

useSearchHighlight 返回:

  1. setQuery(q) → ink.setSearchHighlight(q) → 下一帧 applySearchHighlight 全匹配反色
  2. scanElement(el) → ink.scanElementSubtree(el) → MatchPosition[](元素相对坐标)
  3. setPositions(state|null) → 当前匹配黄色 overlay;state 含 positions、rowOffset、currentIdx

设计理由(源码注释): transcript 里 bash 输出、路径、错误信息在渲染后可能与源 message 不同(截断、省略)。屏幕空间搜索保证「所见即所搜」。

VirtualMessageList 用法: 对每条 message 根 DOM 节点 scanElement 建索引,跳转时 setPositions 高亮当前行。

空 query 清除反色;setPositions(null) 清除当前指示。

源码引用: src/ink/hooks/use-search-highlight.ts · 第 7–53 行(共 54 行)

   7| /**
   8|  * Set the search highlight query on the Ink instance. Non-empty → all
   9|  * visible occurrences are inverted on the next frame (SGR 7, screen-buffer
  10|  * overlay, same damage machinery as selection). Empty → clears.
  11|  *
  12|  * This is a screen-space highlight — it matches the RENDERED text, not the
  13|  * source message text. Works for anything visible (bash output, file paths,
  14|  * error messages) regardless of where it came from in the message tree. A
  15|  * query that matched in source but got truncated/ellipsized in rendering
  16|  * won't highlight; that's acceptable — we highlight what you see.
  17|  */
  18| export function useSearchHighlight(): {
  19|   setQuery: (query: string) => void
  20|   /** Paint an existing DOM subtree (from the MAIN tree) to a fresh
  21|    *  Screen at its natural height, scan. Element-relative positions
  22|    *  (row 0 = element top). Zero context duplication — the element
  23|    *  IS the one built with all real providers. */
  24|   scanElement: (el: DOMElement) => MatchPosition[]
  25|   /** Position-based CURRENT highlight. Every frame writes yellow at
  26|    *  positions[currentIdx] + rowOffset. The scan-highlight (inverse on
  27|    *  all matches) still runs — this overlays on top. rowOffset tracks
  28|    *  scroll; positions stay stable (message-relative). null clears. */
  29|   setPositions: (
  30|     state: {
  31|       positions: MatchPosition[]
  32|       rowOffset: number
  33|       currentIdx: number
  34|     } | null,
  35|   ) => void
  36| } {
  37|   useContext(StdinContext) // anchor to App subtree for hook rules
  38|   const ink = instances.get(process.stdout)
  39|   return useMemo(() => {
  40|     if (!ink) {
  41|       return {
  42|         setQuery: () => {},
  43|         scanElement: () => [],
  44|         setPositions: () => {},
  45|       }
  46|     }
  47|     return {
  48|       setQuery: (query: string) => ink.setSearchHighlight(query),
  49|       scanElement: (el: DOMElement) => ink.scanElementSubtree(el),
  50|       setPositions: state => ink.setSearchPositions(state),
  51|     }
  52|   }, [ink])
  53| }

源码引用: src/ink/searchHighlight.ts · 第 24–35 行(共 94 行)

  24|  * Returns true if any match was highlighted (damage gate — caller forces
  25|  * full-frame damage when true).
  26|  */
  27| export function applySearchHighlight(
  28|   screen: Screen,
  29|   query: string,
  30|   stylePool: StylePool,
  31| ): boolean {
  32|   if (!query) return false
  33|   const lq = query.toLowerCase()
  34|   const qlen = lq.length
  35|   const w = screen.width

useInput 与 useStdin

useStdin 直接 useContext(StdinContext),供需要底层控制的组件使用。

useInput 在 useStdin 之上:

  • useLayoutEffect 管理 raw mode 引用(通过 App.handleSetRawMode)
  • useEffect 注册 internal_eventEmitter.on('input')
  • useEventCallback 稳定 handler,isActive 变化不 reorder listener

Ctrl+C 门控: 当 internal_exitOnCtrlC 且 input==='c' && key.ctrl,跳过 handler。

多个 useInput 时,只有 isActive 的 handler 处理;但 raw mode 只要有一个 active 就保持开启。

PromptInput、REPL 全局快捷键、命令面板可能共享 stdin,排障时列出所有 useInput 的 isActive。

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

   1| import { useContext } from 'react'
   2| import StdinContext from '../components/StdinContext.js'
   3| 
   4| /**
   5|  * `useStdin` is a React hook, which exposes stdin stream.
   6|  */
   7| const useStdin = () => useContext(StdinContext)
   8| export default useStdin
   9| 

源码引用: 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| }

useSelection 与选择模型

全屏 alt-screen 下 Ink 在 ink.tsx 持有 SelectionState:锚点、焦点、拖拽模式(字符/词/行)。

use-selection 暴露读写接口,供可复制文本区域查询是否有选区、获取选中文本(getSelectedText 走 screen buffer)。

选择与搜索高亮共用 damage 路径;prevFrameContaminated 防止 blit 从「已反色」旧帧错误复用。

shiftSelectionForFollow: ScrollBox 粘底滚动时,选择区随内容上移(consumeFollowScroll),模拟真实终端行为。

鼠标多击选词/行由 App → Ink.handleMultiClick 完成,非 hook 层逻辑。

源码引用: src/ink/hooks/use-selection.ts · 第 1–50 行(共 105 行)

   1| import { useContext, useMemo, useSyncExternalStore } from 'react'
   2| import StdinContext from '../components/StdinContext.js'
   3| import instances from '../instances.js'
   4| import {
   5|   type FocusMove,
   6|   type SelectionState,
   7|   shiftAnchor,
   8| } from '../selection.js'
   9| 
  10| /**
  11|  * Access to text selection operations on the Ink instance (fullscreen only).
  12|  * Returns no-op functions when fullscreen mode is disabled.
  13|  */
  14| export function useSelection(): {
  15|   copySelection: () => string
  16|   /** Copy without clearing the highlight (for copy-on-select). */
  17|   copySelectionNoClear: () => string
  18|   clearSelection: () => void
  19|   hasSelection: () => boolean
  20|   /** Read the raw mutable selection state (for drag-to-scroll). */
  21|   getState: () => SelectionState | null
  22|   /** Subscribe to selection mutations (start/update/finish/clear). */
  23|   subscribe: (cb: () => void) => () => void
  24|   /** Shift the anchor row by dRow, clamped to [minRow, maxRow]. */
  25|   shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
  26|   /** Shift anchor AND focus by dRow (keyboard scroll: whole selection
  27|    *  tracks content). Clamped points get col reset to the full-width edge
  28|    *  since their content was captured by captureScrolledRows. Reads
  29|    *  screen.width from the ink instance for the col-reset boundary. */
  30|   shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
  31|   /** Keyboard selection extension (shift+arrow): move focus, anchor fixed.
  32|    *  Left/right wrap across rows; up/down clamp at viewport edges. */
  33|   moveFocus: (move: FocusMove) => void
  34|   /** Capture text from rows about to scroll out of the viewport (call
  35|    *  BEFORE scrollBy so the screen buffer still has the outgoing rows). */
  36|   captureScrolledRows: (
  37|     firstRow: number,
  38|     lastRow: number,
  39|     side: 'above' | 'below',
  40|   ) => void
  41|   /** Set the selection highlight bg color (theme-piping; solid bg
  42|    *  replaces the old SGR-7 inverse so syntax highlighting stays readable
  43|    *  under selection). Call once on mount + whenever theme changes. */
  44|   setSelectionBgColor: (color: string) => void
  45| } {
  46|   // Look up the Ink instance via stdout — same pattern as instances map.
  47|   // StdinContext is available (it's always provided), and the Ink instance
  48|   // is keyed by stdout which we can get from process.stdout since there's
  49|   // only one Ink instance per process in practice.
  50|   useContext(StdinContext) // anchor to App subtree for hook rules

源码引用: src/ink/selection.ts · 第 1–40 行(共 918 行)

   1| /**
   2|  * Text selection state for fullscreen mode.
   3|  *
   4|  * Tracks a linear selection in screen-buffer coordinates (0-indexed col/row).
   5|  * Selection is line-based: cells from (startCol, startRow) through
   6|  * (endCol, endRow) inclusive, wrapping across line boundaries. This matches
   7|  * terminal-native selection behavior (not rectangular/block).
   8|  *
   9|  * The selection is stored as ANCHOR (where the drag started) + FOCUS (where
  10|  * the cursor is now). The rendered highlight normalizes to start ≤ end.
  11|  */
  12| 
  13| import { clamp } from './layout/geometry.js'
  14| import type { Screen, StylePool } from './screen.js'
  15| import { CellWidth, cellAt, cellAtIndex, setCellStyleId } from './screen.js'
  16| 
  17| type Point = { col: number; row: number }
  18| 
  19| export type SelectionState = {
  20|   /** Where the mouse-down occurred. Null when no selection. */
  21|   anchor: Point | null
  22|   /** Current drag position (updated on mouse-move while dragging). */
  23|   focus: Point | null
  24|   /** True between mouse-down and mouse-up. */
  25|   isDragging: boolean
  26|   /** For word/line mode: the initial word/line bounds from the first
  27|    *  multi-click. Drag extends from this span to the word/line at the
  28|    *  current mouse position so the original word/line stays selected
  29|    *  even when dragging backward past it. Null ⇔ char mode. The kind
  30|    *  tells extendSelection whether to snap to word or line boundaries. */
  31|   anchorSpan: { lo: Point; hi: Point; kind: 'word' | 'line' } | null
  32|   /** Text from rows that scrolled out ABOVE the viewport during
  33|    *  drag-to-scroll. The screen buffer only holds the current viewport,
  34|    *  so without this accumulator, dragging down past the bottom edge
  35|    *  loses the top of the selection once the anchor clamps. Prepended
  36|    *  to the on-screen text by getSelectedText. Reset on start/clear. */
  37|   scrolledOffAbove: string[]
  38|   /** Symmetric: rows scrolled out BELOW when dragging up. Appended. */
  39|   scrolledOffBelow: string[]
  40|   /** Soft-wrap bits parallel to scrolledOffAbove — true means the row

use-terminal-viewport 与滚动

use-terminal-viewport 把屏幕坐标与 ScrollBox 视口对齐,供:

  • 拖拽滚动边缘检测(getViewportTop)
  • 将鼠标 row 映射到消息内偏移
  • 与 ScrollBoxHandle.getScrollTop/getViewportHeight 配合

ScrollBox 的 imperative API(scrollToElement、getFreshScrollHeight)在 ink-components 章详解;viewport hook 消费这些值做 UI 同步(如滚动条、搜索命中行居中)。

注意 getFreshScrollHeight 直接读 Yoga,避免节流渲染缓存 16ms 滞后。

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

   1| import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
   2| import { TerminalSizeContext } from '../components/TerminalSizeContext.js'
   3| import type { DOMElement } from '../dom.js'
   4| 
   5| type ViewportEntry = {
   6|   /**
   7|    * Whether the element is currently within the terminal viewport
   8|    */
   9|   isVisible: boolean
  10| }
  11| 
  12| /**
  13|  * Hook to detect if a component is within the terminal viewport.
  14|  *
  15|  * Returns a callback ref and a viewport entry object.
  16|  * Attach the ref to the component you want to track.
  17|  *
  18|  * The entry is updated during the layout phase (useLayoutEffect) so callers
  19|  * always read fresh values during render. Visibility changes do NOT trigger
  20|  * re-renders on their own — callers that re-render for other reasons (e.g.
  21|  * animation ticks, state changes) will pick up the latest value naturally.
  22|  * This avoids infinite update loops when combined with other layout effects
  23|  * that also call setState.
  24|  *
  25|  * @example
  26|  * const [ref, entry] = useTerminalViewport()
  27|  * return <Box ref={ref}><Animation enabled={entry.isVisible}>...</Animation></Box>
  28|  */
  29| export function useTerminalViewport(): [
  30|   ref: (element: DOMElement | null) => void,
  31|   entry: ViewportEntry,
  32| ] {
  33|   const terminalSize = useContext(TerminalSizeContext)
  34|   const elementRef = useRef<DOMElement | null>(null)
  35|   const entryRef = useRef<ViewportEntry>({ isVisible: true })
  36| 
  37|   const setElement = useCallback((el: DOMElement | null) => {
  38|     elementRef.current = el
  39|   }, [])
  40| 
  41|   // Runs on every render because yoga layout values can change
  42|   // without React being aware. Only updates the ref — no setState
  43|   // to avoid cascading re-renders during the commit phase.
  44|   // Walks the DOM ancestor chain fresh each time to avoid holding stale
  45|   // references after yoga tree rebuilds.
  46|   useLayoutEffect(() => {
  47|     const element = elementRef.current
  48|     if (!element?.yogaNode || !terminalSize) {
  49|       return
  50|     }
  51| 
  52|     const height = element.yogaNode.getComputedHeight()
  53|     const rows = terminalSize.rows
  54| 
  55|     // Walk the DOM parent chain (not yoga.getParent()) so we can detect
  56|     // scroll containers and subtract their scrollTop. Yoga computes layout
  57|     // positions without scroll offset — scrollTop is applied at render time.
  58|     // Without this, an element inside a ScrollBox whose yoga position exceeds
  59|     // terminalRows would be considered offscreen even when scrolled into view
  60|     // (e.g., the spinner in fullscreen mode after enough messages accumulate).

源码引用: 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;

use-terminal-focus 与 use-terminal-title

use-terminal-focus 订阅 DEC 1004 焦点 in/out,更新 React 状态,供组件暂停动画或显示「终端失焦」提示。

use-terminal-title 通过 OSC 设置图标/窗口标题(Claude Code 用 sessionStatus 驱动 waiting 动画标题)。

use-tab-status 写 OSC 21337(iTerm2 风格标签圆点),supportsTabStatus() 门控;unmount 时 CLEAR_TAB_STATUS 防残留。

这些 hook 一般只在 App 级或 REPL 壳使用,叶子 message 组件很少直接调用。

源码引用: 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| 

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

   1| import { useContext, useEffect } from 'react'
   2| import stripAnsi from 'strip-ansi'
   3| import { OSC, osc } from '../termio/osc.js'
   4| import { TerminalWriteContext } from '../useTerminalNotification.js'
   5| 
   6| /**
   7|  * Declaratively set the terminal tab/window title.
   8|  *
   9|  * Pass a string to set the title. ANSI escape sequences are stripped
  10|  * automatically so callers don't need to know about terminal encoding.
  11|  * Pass `null` to opt out — the hook becomes a no-op and leaves the
  12|  * terminal title untouched.
  13|  *
  14|  * On Windows, uses `process.title` (classic conhost doesn't support OSC).
  15|  * Elsewhere, writes OSC 0 (set title+icon) via Ink's stdout.
  16|  */
  17| export function useTerminalTitle(title: string | null): void {
  18|   const writeRaw = useContext(TerminalWriteContext)
  19| 
  20|   useEffect(() => {
  21|     if (title === null || !writeRaw) return
  22| 
  23|     const clean = stripAnsi(title)
  24| 
  25|     if (process.platform === 'win32') {
  26|       process.title = clean
  27|     } else {
  28|       writeRaw(osc(OSC.SET_TITLE_AND_ICON, clean))
  29|     }
  30|   }, [title, writeRaw])
  31| }
  32| 

源码引用: src/ink/hooks/use-tab-status.ts · 第 1–40 行(共 73 行)

   1| import { useContext, useEffect, useRef } from 'react'
   2| import {
   3|   CLEAR_TAB_STATUS,
   4|   supportsTabStatus,
   5|   tabStatus,
   6|   wrapForMultiplexer,
   7| } from '../termio/osc.js'
   8| import type { Color } from '../termio/types.js'
   9| import { TerminalWriteContext } from '../useTerminalNotification.js'
  10| 
  11| export type TabStatusKind = 'idle' | 'busy' | 'waiting'
  12| 
  13| const rgb = (r: number, g: number, b: number): Color => ({
  14|   type: 'rgb',
  15|   r,
  16|   g,
  17|   b,
  18| })
  19| 
  20| // Per the OSC 21337 usage guide's suggested mapping.
  21| const TAB_STATUS_PRESETS: Record<
  22|   TabStatusKind,
  23|   { indicator: Color; status: string; statusColor: Color }
  24| > = {
  25|   idle: {
  26|     indicator: rgb(0, 215, 95),
  27|     status: 'Idle',
  28|     statusColor: rgb(136, 136, 136),
  29|   },
  30|   busy: {
  31|     indicator: rgb(255, 149, 0),
  32|     status: 'Working…',
  33|     statusColor: rgb(255, 149, 0),
  34|   },
  35|   waiting: {
  36|     indicator: rgb(95, 135, 255),
  37|     status: 'Waiting',
  38|     statusColor: rgb(95, 135, 255),
  39|   },
  40| }

use-declared-cursor 与 IME

PromptInput 等输入控件用 use-declared-cursor 向 Ink 声明原生光标应出现的屏幕格:

  • 节点引用 + relativeX/Y(框内偏移)
  • ink.tsx 每帧 render 后读 nodeCache 绝对矩形,发 CSI CUP
  • 清除声明时恢复 frame.cursor 逻辑

这使 IME 预编辑、屏幕阅读器能跟踪真实输入位置;alt-screen park 光标在末行后,声明坐标覆盖 park。

use-app 提供 onExit、write 等,与 TerminalWriteProvider 互补:Provider 供 AlternateScreen 内原子写 ANSI。

源码引用: src/ink/hooks/use-declared-cursor.ts · 第 1–50 行(共 74 行)

   1| import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
   2| import CursorDeclarationContext from '../components/CursorDeclarationContext.js'
   3| import type { DOMElement } from '../dom.js'
   4| 
   5| /**
   6|  * Declares where the terminal cursor should be parked after each frame.
   7|  *
   8|  * Terminal emulators render IME preedit text at the physical cursor
   9|  * position, and screen readers / screen magnifiers track the native
  10|  * cursor — so parking it at the text input's caret makes CJK input
  11|  * appear inline and lets accessibility tools follow the input.
  12|  *
  13|  * Returns a ref callback to attach to the Box that contains the input.
  14|  * The declared (line, column) is interpreted relative to that Box's
  15|  * nodeCache rect (populated by renderNodeToOutput).
  16|  *
  17|  * Timing: Both ref attach and useLayoutEffect fire in React's layout
  18|  * phase — after resetAfterCommit calls scheduleRender. scheduleRender
  19|  * defers onRender via queueMicrotask, so onRender runs AFTER layout
  20|  * effects commit and reads the fresh declaration on the first frame
  21|  * (no one-keystroke lag). Test env uses onImmediateRender (synchronous,
  22|  * no microtask), so tests compensate by calling ink.onRender()
  23|  * explicitly after render.
  24|  */
  25| export function useDeclaredCursor({
  26|   line,
  27|   column,
  28|   active,
  29| }: {
  30|   line: number
  31|   column: number
  32|   active: boolean
  33| }): (element: DOMElement | null) => void {
  34|   const setCursorDeclaration = useContext(CursorDeclarationContext)
  35|   const nodeRef = useRef<DOMElement | null>(null)
  36| 
  37|   const setNode = useCallback((node: DOMElement | null) => {
  38|     nodeRef.current = node
  39|   }, [])
  40| 
  41|   // When active, set unconditionally. When inactive, clear conditionally
  42|   // (only if the currently-declared node is ours). The node-identity check
  43|   // handles two hazards:
  44|   //   1. A memo()ized active instance elsewhere (e.g. the search input in
  45|   //      a memo'd Footer) doesn't re-render this commit — an inactive
  46|   //      instance re-rendering here must not clobber it.
  47|   //   2. Sibling handoff (menu focus moving between list items) — when
  48|   //      focus moves opposite to sibling order, the newly-inactive item's
  49|   //      effect runs AFTER the newly-active item's set. Without the node
  50|   //      check it would clobber.

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

 653|     // which doesn't track damage, and prev-frame overlay cells need to be
 654|     // compared when selection moves/clears. prevFrameContaminated covers
 655|     // the frame-after-selection-clears case.
 656|     let selActive = false
 657|     let hlActive = false
 658|     if (this.altScreenActive) {
 659|       selActive = hasSelection(this.selection)
 660|       if (selActive) {
 661|         applySelectionOverlay(frame.screen, this.selection, this.stylePool)
 662|       }
 663|       // Scan-highlight: inverse on ALL visible matches (less/vim style).
 664|       // Position-highlight (below) overlays CURRENT (yellow) on top.
 665|       hlActive = applySearchHighlight(
 666|         frame.screen,
 667|         this.searchHighlightQuery,
 668|         this.stylePool,
 669|       )
 670|       // Position-based CURRENT: write yellow at positions[currentIdx] +
 671|       // rowOffset. No scanning — positions came from a prior scan when
 672|       // the message first mounted. Message-relative + rowOffset = screen.
 673|       if (this.searchPositions) {
 674|         const sp = this.searchPositions
 675|         const posApplied = applyPositionedHighlight(
 676|           frame.screen,
 677|           this.stylePool,
 678|           sp.positions,
 679|           sp.rowOffset,
 680|           sp.currentIdx,
 681|         )
 682|         hlActive = hlActive || posApplied
 683|       }
 684|     }
 685| 
 686|     // Full-damage backstop: applies on BOTH alt-screen and main-screen.
 687|     // Layout shifts (spinner appears, status line resizes) can leave stale
 688|     // cells at sibling boundaries that per-node damage tracking misses.
 689|     // Selection/highlight overlays write via setCellStyleId which doesn't
 690|     // track damage. prevFrameContaminated covers the cleanup frame.
 691|     if (
 692|       didLayoutShift() ||
 693|       selActive ||
 694|       hlActive ||
 695|       this.prevFrameContaminated
 696|     ) {
 697|       frame.screen.damage = {
 698|         x: 0,
 699|         y: 0,
 700|         width: frame.screen.width,
 701|         height: frame.screen.height,
 702|       }
 703|     }
 704| 
 705|     // Alt-screen: anchor the physical cursor to (0,0) before every diff.
 706|     // All cursor moves in log-update are RELATIVE to prev.cursor; if tmux
 707|     // (or any emulator) perturbs the physical cursor out-of-band (status
 708|     // bar refresh, pane redraw, Cmd+K wipe), the relative moves drift and
 709|     // content creeps up 1 row/frame. CSI H resets the physical cursor;
 710|     // passing prev.cursor=(0,0) makes the diff compute from the same spot.
 711|     // Self-healing against any external cursor manipulation. Main-screen
 712|     // can't do this — cursor.y tracks scrollback rows CSI H can't reach.
 713|     // The CSI H write is deferred until after the diff is computed so we
 714|     // can skip it for empty diffs (no writes → physical cursor unused).

时钟类 hooks

ClockContext(components/ClockContext.tsx)提供单调时钟;use-interval 与 use-animation-frame 在回调中 markDirty 或 setState,驱动 spinner、等待动画。

与 FRAME_INTERVAL_MS 节流独立:动画 hook 可能更频繁 markDirty,但 onRender 仍被 throttle 合并到 ~60fps。

避免在 interval 回调里做重 DOM 测量;只改轻量 state,让 Yoga 在下一帧统一布局。

源码引用: src/ink/hooks/use-interval.ts · 第 1–35 行(共 68 行)

   1| import { useContext, useEffect, useRef, useState } from 'react'
   2| import { ClockContext } from '../components/ClockContext.js'
   3| 
   4| /**
   5|  * Returns the clock time, updating at the given interval.
   6|  * Subscribes as non-keepAlive — won't keep the clock alive on its own,
   7|  * but updates whenever a keepAlive subscriber (e.g. the spinner)
   8|  * is driving the clock.
   9|  *
  10|  * Use this to drive pure time-based computations (shimmer position,
  11|  * frame index) from the shared clock.
  12|  */
  13| export function useAnimationTimer(intervalMs: number): number {
  14|   const clock = useContext(ClockContext)
  15|   const [time, setTime] = useState(() => clock?.now() ?? 0)
  16| 
  17|   useEffect(() => {
  18|     if (!clock) return
  19| 
  20|     let lastUpdate = clock.now()
  21| 
  22|     const onChange = (): void => {
  23|       const now = clock.now()
  24|       if (now - lastUpdate >= intervalMs) {
  25|         lastUpdate = now
  26|         setTime(now)
  27|       }
  28|     }
  29| 
  30|     return clock.subscribe(onChange, false)
  31|   }, [clock, intervalMs])
  32| 
  33|   return time
  34| }
  35| 

源码引用: src/ink/hooks/use-animation-frame.ts · 第 1–35 行(共 58 行)

   1| import { useContext, useEffect, useState } from 'react'
   2| import { ClockContext } from '../components/ClockContext.js'
   3| import type { DOMElement } from '../dom.js'
   4| import { useTerminalViewport } from './use-terminal-viewport.js'
   5| 
   6| /**
   7|  * Hook for synchronized animations that pause when offscreen.
   8|  *
   9|  * Returns a ref to attach to the animated element and the current animation time.
  10|  * All instances share the same clock, so animations stay in sync.
  11|  * The clock only runs when at least one keepAlive subscriber exists.
  12|  *
  13|  * Pass `null` to pause — unsubscribes from the clock so no ticks fire.
  14|  * Time freezes at the last value and resumes from the current clock time
  15|  * when a number is passed again.
  16|  *
  17|  * @param intervalMs - How often to update, or null to pause
  18|  * @returns [ref, time] - Ref to attach to element, elapsed time in ms
  19|  *
  20|  * @example
  21|  * function Spinner() {
  22|  *   const [ref, time] = useAnimationFrame(120)
  23|  *   const frame = Math.floor(time / 120) % FRAMES.length
  24|  *   return <Box ref={ref}>{FRAMES[frame]}</Box>
  25|  * }
  26|  *
  27|  * The clock automatically slows when the terminal is blurred,
  28|  * so consumers don't need to handle focus state.
  29|  */
  30| export function useAnimationFrame(
  31|   intervalMs: number | null = 16,
  32| ): [ref: (element: DOMElement | null) => void, time: number] {
  33|   const clock = useContext(ClockContext)
  34|   const [viewportRef, { isVisible }] = useTerminalViewport()
  35|   const [time, setTime] = useState(() => clock?.now() ?? 0)

use-app 与测试替身

use-app 读取 AppContext,暴露 exit(触发 Ink unmount)、stdout/stderr 流引用。本地命令 /config 等 modal 有时需要直接 write OSC,应优先 TerminalWriteProvider 的 writeRaw,与 AppContext 写路径保持一致。

测试文件 testing.tsx 提供精简 render 助手,省略 onStdinResume、onCursorDeclaration 等可选 App props;hook 在无 Ink 实例时降级 no-op,避免 CI 环境抛错。集成测试若要模拟搜索,需先 render 真实树再取 instances.get(stdout)。

REPL 集成示例(概念)

典型 REPL 搜索链路:

  1. 用户打开搜索 → useSearchHighlight().setQuery
  2. 构建索引 → 对每条 message 根节点 scanElement
  3. 上下跳转 → setPositions({ positions, currentIdx, rowOffset }),rowOffset 来自 VirtualMessageList 滚动
  4. 关闭搜索 → setQuery('') + setPositions(null)

输入链路:PromptInput useInput isActive 仅在焦点在输入框;REPL useGlobalKeybindings 在 src/hooks 层,可能另一 isActive 规则。

权限弹窗 overlay 时,应 deactivate 底层 useInput,防止按键穿透。

源码目录

从 use-search-highlight.ts 与 use-input.ts 开始;对照 ink.tsx 中 setSearchHighlight / scanElementSubtree 实例方法。

本章小结与延伸

ink hooks 是终端能力的 React 门面。REPL 搜索、输入框、全屏滚动应优先用这些 API 而非直接操作 stdout。 继续学习:

  • Ink 组件
  • 终端事件
Prev
终端事件 · resize、paste、stdin
Next
Ink 组件 · Box、Text、ScrollBox 原语