本章总览
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。
建议学习步骤
- 阅读 Box Props 与 reconciler 事件映射
- 阅读 Text 的 memoizedStylesForWrap
- 阅读 ScrollBox handle 方法列表
- 阅读 AlternateScreen 的 mount/unmount 序列
- 对照 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>
用户代码通常再包 <AlternateScreen><FullscreenLayout>...</FullscreenLayout></AlternateScreen>。
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 组件大量用 <Text wrap="wrap-trim"> 展示 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 内。 继续学习: