本章总览
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)。
建议学习步骤
- 阅读 use-search-highlight 与 ink.tsx 实例方法
- 阅读 use-stdin / use-input 依赖链
- 阅读 use-terminal-viewport 与 ScrollBox handle
- 阅读 use-selection 与 selection.ts
- 浏览 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.ts | raw mode + input 事件 |
| use-app.ts | AppContext(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.ts | OSC 21337 标签状态点 |
| use-declared-cursor.ts | IME 光标声明 |
| use-interval.ts / use-animation-frame.ts | 时钟驱动重绘 |
Claude Code 业务层 src/hooks/useTerminalSize.ts 等与 TerminalSizeContext 配合,不要与上表混淆。
useSearchHighlight:三件套 API
useSearchHighlight 返回:
- setQuery(q) →
ink.setSearchHighlight(q)→ 下一帧applySearchHighlight全匹配反色 - scanElement(el) →
ink.scanElementSubtree(el)→ MatchPosition[](元素相对坐标) - 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 搜索链路:
- 用户打开搜索 →
useSearchHighlight().setQuery - 构建索引 → 对每条 message 根节点
scanElement - 上下跳转 →
setPositions({ positions, currentIdx, rowOffset }),rowOffset 来自 VirtualMessageList 滚动 - 关闭搜索 → 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。 继续学习: