本章总览
Claude Code 终端交互的入口是 stdin raw mode 下的字节流。App.tsx 在 readable 上循环 read(),交给 parse-keypress 状态机拆成键、粘贴块、鼠标 SGR、终端查询响应;再经 EventEmitter(legacy)与 Dispatcher(DOM 风格)两路分发。本章覆盖 resize、bracketed paste、stdin 间隙恢复、焦点事件 与 useInput 契约。
学完本章你应该能
- 说明 rawModeEnabledCount 引用计数与 bracketed paste 启停时机
- 解释 parse-keypress 对 IN_PASTE 与 incomplete escape 的超时策略
- 理解 resize 如何同步更新 Yoga 与 TerminalSizeContext
- 掌握 ResizeEvent 在 Dispatcher 中的优先级与冒泡
- 能追踪 Ctrl+C、SIGCONT、stdin 5s gap 的终端模式重断言路径
核心概念(先读懂这些)
双通道输入:Emitter 与 DOM Dispatcher
useInput 订阅 internal_eventEmitter 的 input 事件(InputEvent 包装 ParsedKey)。Box 的 onKeyDown 等走 reconciler Dispatcher,collectListeners 按捕获/冒泡顺序执行。同一按键可能两路都收到,组件应 stopImmediatePropagation 或 isActive 门控避免重复处理。
粘贴是原子 input 字符串
Bracketed paste(EBP/DBP)由 App 在 raw mode 开关时写入。解析器进入 IN_PASTE 模式,整段 bracket 内容作为一次 input 回调传给 useInput,避免逐字符风暴。PASTE_TIMEOUT(500ms)长于普通 ESC 超时(50ms)。
resize 同步而非节流
stdout resize 事件在 handleResize 同步更新 terminalColumns/Rows、Yoga 根宽度、needsEraseBeforePaint,并立即 render(currentNode)。注释明确反对在 resize 时 scheduleRender,防止 Yoga 尺寸与帧内容不一致。
建议学习步骤
- 阅读 StdinContext 暴露的 API
- 阅读 App handleSetRawMode 与 handleReadable
- 阅读 parse-keypress 的 PASTE 与 CSI u 正则
- 阅读 ink.tsx handleResize
- 阅读 events/dispatcher resize 优先级
常见误区
注意
earlyInput 捕获与 Ink readable 互斥,raw mode 前必须 stopCapturingEarlyInput
注意
flushIncomplete 在 readableLength>0 时 re-arm,避免丢鼠标序列后半段
注意
Windows 无 SIGSTOP,SUPPORTS_SUSPEND 为 false
StdinContext:raw mode 与查询器
StdinContext.ts 向整棵 React 树提供:
| 字段 | 用途 |
|---|---|
| stdin | 默认 process.stdin,可 render 时覆盖 |
| setRawMode | 引用计数式开关,禁止直接用 stdin.setRawMode |
| isRawModeSupported | TTY 且支持 setRawMode |
| internal_eventEmitter | useInput 订阅的 input 事件源 |
| internal_exitOnCtrlC | false 时 Ctrl+C 交给应用处理 |
| internal_querier | TerminalQuerier,发 DECRQM/XTVERSION 等 |
Ink 的 <App> 在 render() 时注入真实 stdin/stdout。测试替身可省略 onStdinResume 等可选回调。
源码引用: src/ink/components/StdinContext.ts · 第 5–49 行(共 50 行)
5| export type Props = {
6| /**
7| * Stdin stream passed to `render()` in `options.stdin` or `process.stdin` by default. Useful if your app needs to handle user input.
8| */
9| readonly stdin: NodeJS.ReadStream
10|
11| /**
12| * Ink exposes this function via own `<StdinContext>` to be able to handle Ctrl+C, that's why you should use Ink's `setRawMode` instead of `process.stdin.setRawMode`.
13| * If the `stdin` stream passed to Ink does not support setRawMode, this function does nothing.
14| */
15| readonly setRawMode: (value: boolean) => void
16|
17| /**
18| * A boolean flag determining if the current `stdin` supports `setRawMode`. A component using `setRawMode` might want to use `isRawModeSupported` to nicely fall back in environments where raw mode is not supported.
19| */
20| readonly isRawModeSupported: boolean
21|
22| readonly internal_exitOnCtrlC: boolean
23|
24| readonly internal_eventEmitter: EventEmitter
25|
26| /** Query the terminal and await responses (DECRQM, OSC 11, etc.).
27| * Null only in the never-reached default context value. */
28| readonly internal_querier: TerminalQuerier | null
29| }
30|
31| /**
32| * `StdinContext` is a React context, which exposes input stream.
33| */
34|
35| const StdinContext = createContext<Props>({
36| stdin: process.stdin,
37|
38| internal_eventEmitter: new EventEmitter(),
39| setRawMode() {},
40| isRawModeSupported: false,
41|
42| internal_exitOnCtrlC: true,
43| internal_querier: null,
44| })
45|
46| // eslint-disable-next-line custom-rules/no-top-level-side-effects
47| StdinContext.displayName = 'InternalStdinContext'
48|
49| export default StdinContext
App.tsx:启用 raw mode 的副作用
首次 setRawMode(true)(rawModeEnabledCount 0→1)时 App 顺序执行:
- stopCapturingEarlyInput() — 与 bootstrap 早期 stdin 捕获互斥
stdin.ref()+setRawMode(true)+readable→handleReadable- EBP bracketed paste、EFE 焦点报告
- 若
supportsExtendedKeys():ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS - setImmediate 发 XTVERSION 查询,结果
setXtversionName(SSH 友好)
关闭时逆序:DISABLE 键协议、DFE、DBP、removeListener、unref。多个 useInput 共享计数,最后一个关闭才恢复 cooked mode。
useLayoutEffect vs useEffect: use-input 用 useLayoutEffect 启 raw mode,保证 commit 后同 tick 内终端不再回显按键。
源码引用: src/ink/components/App.tsx · 第 209–280 行(共 778 行)
209| setRawMode: this.handleSetRawMode,
210| isRawModeSupported: this.isRawModeSupported(),
211|
212| internal_exitOnCtrlC: this.props.exitOnCtrlC,
213|
214| internal_eventEmitter: this.internal_eventEmitter,
215| internal_querier: this.querier,
216| }}
217| >
218| <TerminalFocusProvider>
219| <ClockProvider>
220| <CursorDeclarationContext.Provider
221| value={this.props.onCursorDeclaration ?? (() => {})}
222| >
223| {this.state.error ? (
224| <ErrorOverview error={this.state.error as Error} />
225| ) : (
226| this.props.children
227| )}
228| </CursorDeclarationContext.Provider>
229| </ClockProvider>
230| </TerminalFocusProvider>
231| </StdinContext.Provider>
232| </AppContext.Provider>
233| </TerminalSizeContext.Provider>
234| )
235| }
236|
237| override componentDidMount() {
238| // In accessibility mode, keep the native cursor visible for screen magnifiers and other tools
239| if (
240| this.props.stdout.isTTY &&
241| !isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)
242| ) {
243| this.props.stdout.write(HIDE_CURSOR)
244| }
245| }
246|
247| override componentWillUnmount() {
248| if (this.props.stdout.isTTY) {
249| this.props.stdout.write(SHOW_CURSOR)
250| }
251|
252| // Clear any pending timers
253| if (this.incompleteEscapeTimer) {
254| clearTimeout(this.incompleteEscapeTimer)
255| this.incompleteEscapeTimer = null
256| }
257| if (this.pendingHyperlinkTimer) {
258| clearTimeout(this.pendingHyperlinkTimer)
259| this.pendingHyperlinkTimer = null
260| }
261| // ignore calling setRawMode on an handle stdin it cannot be called
262| if (this.isRawModeSupported()) {
263| this.handleSetRawMode(false)
264| }
265| }
266|
267| override componentDidCatch(error: Error) {
268| this.handleExit(error)
269| }
270|
271| handleSetRawMode = (isEnabled: boolean): void => {
272| const { stdin } = this.props
273|
274| if (!this.isRawModeSupported()) {
275| if (stdin === process.stdin) {
276| throw new Error(
277| 'Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported',
278| )
279| } else {
280| throw new Error(
源码引用: src/ink/hooks/use-input.ts · 第 42–90 行(共 93 行)
42| const useInput = (inputHandler: Handler, options: Options = {}) => {
43| const { setRawMode, internal_exitOnCtrlC, internal_eventEmitter } = useStdin()
44|
45| // useLayoutEffect (not useEffect) so that raw mode is enabled synchronously
46| // during React's commit phase, before render() returns. With useEffect, raw
47| // mode setup is deferred to the next event loop tick via React's scheduler,
48| // leaving the terminal in cooked mode — keystrokes echo and the cursor is
49| // visible until the effect fires.
50| useLayoutEffect(() => {
51| if (options.isActive === false) {
52| return
53| }
54|
55| setRawMode(true)
56|
57| return () => {
58| setRawMode(false)
59| }
60| }, [options.isActive, setRawMode])
61|
62| // Register the listener once on mount so its slot in the EventEmitter's
63| // listener array is stable. If isActive were in the effect's deps, the
64| // listener would re-append on false→true, moving it behind listeners
65| // that registered while it was inactive — breaking
66| // stopImmediatePropagation() ordering. useEventCallback keeps the
67| // reference stable while reading latest isActive/inputHandler from
68| // closure (it syncs via useLayoutEffect, so it's compiler-safe).
69| const handleData = useEventCallback((event: InputEvent) => {
70| if (options.isActive === false) {
71| return
72| }
73| const { input, key } = event
74|
75| // If app is not supposed to exit on Ctrl+C, then let input listener handle it
76| // Note: discreteUpdates is called at the App level when emitting events,
77| // so all listeners are already within a high-priority update context.
78| if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) {
79| inputHandler(input, key, event)
80| }
81| })
82|
83| useEffect(() => {
84| internal_eventEmitter?.on('input', handleData)
85|
86| return () => {
87| internal_eventEmitter?.removeListener('input', handleData)
88| }
89| }, [internal_eventEmitter, handleData])
90| }
handleReadable 与 processInput
handleReadable 在 read 循环前检测 STDIN_RESUME_GAP_MS(5s):
- 超过间隔则调用
onStdinResume→ InkreassertTerminalModes(alt-screen、鼠标、扩展键) - 应对 tmux attach、SSH 重连、笔记本唤醒后终端 DEC 模式被重置
processInput 调用 parseMultipleKeypresses,若有 key 则 reconciler.discreteUpdates(processKeysInBatch) 一次 处理整批(粘贴/长按)。
incomplete escape: 半个 CSI/鼠标序列会设 timer;fullscreen 下若 readableLength>0 则推迟 flush(timer 先于 poll 触发的问题)。IN_PASTE 用 500ms 超时。
Bun 下 readable 抛错会 logError,避免未捕获异常杀死进程。
源码引用: src/ink/components/App.tsx · 第 282–348 行(共 778 行)
282| )
283| }
284| }
285|
286| stdin.setEncoding('utf8')
287|
288| if (isEnabled) {
289| // Ensure raw mode is enabled only once
290| if (this.rawModeEnabledCount === 0) {
291| // Stop early input capture right before we add our own readable handler.
292| // Both use the same stdin 'readable' + read() pattern, so they can't
293| // coexist -- our handler would drain stdin before Ink's can see it.
294| // The buffered text is preserved for REPL.tsx via consumeEarlyInput().
295| stopCapturingEarlyInput()
296| stdin.ref()
297| stdin.setRawMode(true)
298| stdin.addListener('readable', this.handleReadable)
299| // Enable bracketed paste mode
300| this.props.stdout.write(EBP)
301| // Enable terminal focus reporting (DECSET 1004)
302| this.props.stdout.write(EFE)
303| // Enable extended key reporting so ctrl+shift+<letter> is
304| // distinguishable from ctrl+<letter>. We write both the kitty stack
305| // push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) —
306| // terminals honor whichever they implement (tmux only accepts the
307| // latter).
308| if (supportsExtendedKeys()) {
309| this.props.stdout.write(ENABLE_KITTY_KEYBOARD)
310| this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS)
311| }
312| // Probe terminal identity. XTVERSION survives SSH (query/reply goes
313| // through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base
314| // detection when env vars are absent. Fire-and-forget: the DA1
315| // sentinel bounds the round-trip, and if the terminal ignores the
316| // query, flush() still resolves and name stays undefined.
317| // Deferred to next tick so it fires AFTER the current synchronous
318| // init sequence completes — avoids interleaving with alt-screen/mouse
319| // tracking enable writes that may happen in the same render cycle.
320| setImmediate(() => {
321| void Promise.all([
322| this.querier.send(xtversion()),
323| this.querier.flush(),
324| ]).then(([r]) => {
325| if (r) {
326| setXtversionName(r.name)
327| logForDebugging(`XTVERSION: terminal identified as "${r.name}"`)
328| } else {
329| logForDebugging('XTVERSION: no reply (terminal ignored query)')
330| }
331| })
332| })
333| }
334|
335| this.rawModeEnabledCount++
336| return
337| }
338|
339| // Disable raw mode only when no components left that are using it
340| if (--this.rawModeEnabledCount === 0) {
341| this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS)
342| this.props.stdout.write(DISABLE_KITTY_KEYBOARD)
343| // Disable terminal focus reporting (DECSET 1004)
344| this.props.stdout.write(DFE)
345| // Disable bracketed paste mode
346| this.props.stdout.write(DBP)
347| stdin.setRawMode(false)
348| stdin.removeListener('readable', this.handleReadable)
源码引用: src/ink/components/App.tsx · 第 30–35 行(共 778 行)
30| import {
31| getTerminalFocused,
32| setTerminalFocused,
33| } from '../terminal-focus-state.js'
34| import { TerminalQuerier, xtversion } from '../terminal-querier.js'
35| import {
parse-keypress:序列识别
parse-keypress.ts 用 termio tokenizer 切分字节流,再匹配:
- META / FN / CSI u / modifyOtherKeys — 现代终端与 tmux 的 Shift+Enter 等
- PASTE_START / PASTE_END — bracketed paste 块
- DECRPM / DA1 / DA2 / Kitty flags / DECXCPR — 查询响应路由到 querier
- XTVERSION_RE — DCS 终端名
- SGR_MOUSE_RE — 按下/释放/滚轮/拖拽(button 64/65 滚轮)
createPasteKey 生成 isPasted: true 的 ParsedKey,上层可把整段文本当作一次编辑。
终端响应与按键共用同一 stdin 泵,因此 parser 必须优先识别响应模式,避免把 ESC[?... 误当方向键。
源码引用: src/ink/parse-keypress.ts · 第 1–80 行(共 802 行)
1| /**
2| * Keyboard input parser - converts terminal input to key events
3| *
4| * Uses the termio tokenizer for escape sequence boundary detection,
5| * then interprets sequences as keypresses.
6| */
7| import { Buffer } from 'buffer'
8| import { PASTE_END, PASTE_START } from './termio/csi.js'
9| import { createTokenizer, type Tokenizer } from './termio/tokenize.js'
10|
11| // eslint-disable-next-line no-control-regex
12| const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/
13|
14| // eslint-disable-next-line no-control-regex
15| const FN_KEY_RE =
16| // eslint-disable-next-line no-control-regex
17| /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/
18|
19| // CSI u (kitty keyboard protocol): ESC [ codepoint [; modifier] u
20| // Example: ESC[13;2u = Shift+Enter, ESC[27u = Escape (no modifiers)
21| // Modifier is optional - when absent, defaults to 1 (no modifiers)
22| // eslint-disable-next-line no-control-regex
23| const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/
24|
25| // xterm modifyOtherKeys: ESC [ 27 ; modifier ; keycode ~
26| // Example: ESC[27;2;13~ = Shift+Enter. Emitted by Ghostty/tmux/xterm when
27| // modifyOtherKeys=2 is active or via user keybinds, typically over SSH where
28| // TERM sniffing misses Ghostty and we never push Kitty keyboard mode.
29| // Note param order is reversed vs CSI u (modifier first, keycode second).
30| // eslint-disable-next-line no-control-regex
31| const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/
32|
33| // -- Terminal response patterns (inbound sequences from the terminal itself) --
34| // DECRPM: CSI ? Ps ; Pm $ y — response to DECRQM (request mode)
35| // eslint-disable-next-line no-control-regex
36| const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/
37| // DA1: CSI ? Ps ; ... c — primary device attributes response
38| // eslint-disable-next-line no-control-regex
39| const DA1_RE = /^\x1b\[\?([\d;]*)c$/
40| // DA2: CSI > Ps ; ... c — secondary device attributes response
41| // eslint-disable-next-line no-control-regex
42| const DA2_RE = /^\x1b\[>([\d;]*)c$/
43| // Kitty keyboard flags: CSI ? flags u — response to CSI ? u query
44| // (private ? marker distinguishes from CSI u key events)
45| // eslint-disable-next-line no-control-regex
46| const KITTY_FLAGS_RE = /^\x1b\[\?(\d+)u$/
47| // DECXCPR cursor position: CSI ? row ; col R
48| // The ? marker disambiguates from modified F3 keys (Shift+F3 = CSI 1;2 R,
49| // Ctrl+F3 = CSI 1;5 R, etc.) — plain CSI row;col R is genuinely ambiguous.
50| // eslint-disable-next-line no-control-regex
51| const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/
52| // OSC response: OSC code ; data (BEL|ST)
53| // eslint-disable-next-line no-control-regex
54| const OSC_RESPONSE_RE = /^\x1b\](\d+);(.*?)(?:\x07|\x1b\\)$/s
55| // XTVERSION: DCS > | name ST — terminal name/version string (answer to CSI > 0 q).
56| // xterm.js replies "xterm.js(X.Y.Z)"; Ghostty, kitty, iTerm2, etc. reply with
57| // their own name. Unlike TERM_PROGRAM, this survives SSH since the query/reply
58| // goes through the pty, not the environment.
59| // eslint-disable-next-line no-control-regex
60| const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
61| // SGR mouse event: CSI < button ; col ; row M (press) or m (release)
62| // Button codes: 64=wheel-up, 65=wheel-down (0x40 | wheel-bit).
63| // Button 32=left-drag (0x20 | motion-bit). Plain 0/1/2 = left/mid/right click.
64| // eslint-disable-next-line no-control-regex
65| const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
66|
67| function createPasteKey(content: string): ParsedKey {
68| return {
69| kind: 'key',
70| name: '',
71| fn: false,
72| ctrl: false,
73| meta: false,
74| shift: false,
75| option: false,
76| super: false,
77| sequence: content,
78| raw: content,
79| isPasted: true,
80| }
源码引用: src/ink/parse-keypress.ts · 第 67–80 行(共 802 行)
67| function createPasteKey(content: string): ParsedKey {
68| return {
69| kind: 'key',
70| name: '',
71| fn: false,
72| ctrl: false,
73| meta: false,
74| shift: false,
75| option: false,
76| super: false,
77| sequence: content,
78| raw: content,
79| isPasted: true,
80| }
InputEvent 与 useInput 契约
InputEvent(events/input-event.ts)包装 input 字符串与 Key 对象(leftArrow、ctrl、meta、isPasted 等)。
useInput 行为摘要:
isActive: false时不启 raw mode、handler 早退- listener 只在 mount 注册一次,
useEventCallback读最新 handler/isActive,保持 stopImmediatePropagation 顺序稳定 - Ctrl+C:若 internal_exitOnCtrlC 为 true,不调用 handler(Ink 处理退出)
多 hook 并存: 每个 useInput 都 setRawMode(true),靠 App 计数;inactive hook 仍占位 listener 但立即 return。
REPL 的 PromptInput、全局快捷键、搜索框可能各有一个 useInput,设计快捷键时注意 isActive 互斥。
源码引用: src/ink/hooks/use-input.ts · 第 18–40 行(共 93 行)
18| /**
19| * This hook is used for handling user input.
20| * It's a more convenient alternative to using `StdinContext` and listening to `data` events.
21| * The callback you pass to `useInput` is called for each character when user enters any input.
22| * However, if user pastes text and it's more than one character, the callback will be called only once and the whole string will be passed as `input`.
23| *
24| * ```
25| * import {useInput} from 'ink';
26| *
27| * const UserInput = () => {
28| * useInput((input, key) => {
29| * if (input === 'q') {
30| * // Exit program
31| * }
32| *
33| * if (key.leftArrow) {
34| * // Left arrow key pressed
35| * }
36| * });
37| *
38| * return …
39| * };
40| * ```
源码引用: src/ink/hooks/use-input.ts · 第 62–89 行(共 93 行)
62| // Register the listener once on mount so its slot in the EventEmitter's
63| // listener array is stable. If isActive were in the effect's deps, the
64| // listener would re-append on false→true, moving it behind listeners
65| // that registered while it was inactive — breaking
66| // stopImmediatePropagation() ordering. useEventCallback keeps the
67| // reference stable while reading latest isActive/inputHandler from
68| // closure (it syncs via useLayoutEffect, so it's compiler-safe).
69| const handleData = useEventCallback((event: InputEvent) => {
70| if (options.isActive === false) {
71| return
72| }
73| const { input, key } = event
74|
75| // If app is not supposed to exit on Ctrl+C, then let input listener handle it
76| // Note: discreteUpdates is called at the App level when emitting events,
77| // so all listeners are already within a high-priority update context.
78| if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) {
79| inputHandler(input, key, event)
80| }
81| })
82|
83| useEffect(() => {
84| internal_eventEmitter?.on('input', handleData)
85|
86| return () => {
87| internal_eventEmitter?.removeListener('input', handleData)
88| }
89| }, [internal_eventEmitter, handleData])
resize:SIGWINCH 到 React 上下文
Ink 构造时 stdout.on('resize', handleResize):
- 读
columns/rows,若与缓存相同则 return(抑制双事件) - 更新
terminalColumns/Rows、altScreenParkPatch、Yoga 根setWidth+calculateLayout - 设置
needsEraseBeforePaint(alt-screen 下一帧 ERASE+HOME 在原子写内) - 同步
render(currentNode)— 不用 scheduleRender
TerminalSizeContext(components/TerminalSizeContext.tsx)把尺寸传给子树,布局组件应用 useTerminalSize(hooks 在 src/hooks,非 ink/hooks)。
ResizeEvent 类为空标记;Dispatcher 对 resize 使用较低优先级连续事件,供 ScrollBox 等订阅 onResize。
源码引用: src/ink/ink.tsx · 第 308–328 行(共 2006 行)
308| this.unsubscribeTTYHandlers = () => {
309| options.stdout.off('resize', this.handleResize)
310| process.off('SIGCONT', this.handleResume)
311| }
312| }
313|
314| this.rootNode = dom.createNode('ink-root')
315| this.focusManager = new FocusManager((target, event) =>
316| dispatcher.dispatchDiscrete(target, event),
317| )
318| this.rootNode.focusManager = this.focusManager
319| this.renderer = createRenderer(this.rootNode, this.stylePool)
320| this.rootNode.onRender = this.scheduleRender
321| this.rootNode.onImmediateRender = this.onRender
322| this.rootNode.onComputeLayout = () => {
323| // Calculate layout during React's commit phase so useLayoutEffect hooks
324| // have access to fresh layout data
325| // Guard against accessing freed Yoga nodes after unmount
326| if (this.isUnmounted) {
327| return
328| }
源码引用: src/ink/events/resize-event.ts · 第 1–1 行(共 2 行)
1| export class ResizeEvent {}
源码引用: src/ink/events/event-handlers.ts · 第 50–54 行(共 74 行)
50| blur: { bubble: 'onBlur', capture: 'onBlurCapture' },
51| paste: { bubble: 'onPaste', capture: 'onPasteCapture' },
52| resize: { bubble: 'onResize' },
53| click: { bubble: 'onClick' },
54| }
Dispatcher:DOM 风格事件
events/dispatcher.ts 实现与 react-dom 类似的三段收集:
- 从 target 向上走 parentNode
- capture handler unshift → 根先执行
- bubble handler push → 叶先执行(target 自身两段都有)
dispatchEvent 根据 event 类型选 Discrete / Continuous / Default 优先级(react-reconciler constants)。resize、scroll、mouse move 归为 continuous,降低与按键离散更新的穿插。
stopPropagation / stopImmediatePropagation 在 listener 循环中检查。Box 的 onClick 仅在 AlternateScreen 开启鼠标跟踪后由 Ink hit-test 触发。
源码引用: src/ink/events/dispatcher.ts · 第 36–79 行(共 234 行)
36| /**
37| * Collect all listeners for an event in dispatch order.
38| *
39| * Uses react-dom's two-phase accumulation pattern:
40| * - Walk from target to root
41| * - Capture handlers are prepended (unshift) → root-first
42| * - Bubble handlers are appended (push) → target-first
43| *
44| * Result: [root-cap, ..., parent-cap, target-cap, target-bub, parent-bub, ..., root-bub]
45| */
46| function collectListeners(
47| target: EventTarget,
48| event: TerminalEvent,
49| ): DispatchListener[] {
50| const listeners: DispatchListener[] = []
51|
52| let node: EventTarget | undefined = target
53| while (node) {
54| const isTarget = node === target
55|
56| const captureHandler = getHandler(node, event.type, true)
57| const bubbleHandler = getHandler(node, event.type, false)
58|
59| if (captureHandler) {
60| listeners.unshift({
61| node,
62| handler: captureHandler,
63| phase: isTarget ? 'at_target' : 'capturing',
64| })
65| }
66|
67| if (bubbleHandler && (event.bubbles || isTarget)) {
68| listeners.push({
69| node,
70| handler: bubbleHandler,
71| phase: isTarget ? 'at_target' : 'bubbling',
72| })
73| }
74|
75| node = node.parentNode
76| }
77|
78| return listeners
79| }
源码引用: src/ink/events/dispatcher.ts · 第 125–135 行(共 234 行)
125| case 'keyup':
126| case 'click':
127| case 'focus':
128| case 'blur':
129| case 'paste':
130| return DiscreteEventPriority as number
131| case 'resize':
132| case 'scroll':
133| case 'mousemove':
134| return ContinuousEventPriority as number
135| default:
终端焦点与粘贴模式
TerminalFocusEvent 与 DECSET 1004 焦点 in/out 序列对应;terminal-focus-state.ts 维护全局聚焦标志,use-terminal-focus hook 暴露给 UI(标题栏样式、暂停输入等)。
Bracketed paste: 粘贴内容带边界,解析器不会把粘贴里的 ESC 当独立序列。关闭 raw mode 必须 DBP,否则终端仍发 bracket 包裹文本。
SIGCONT: ink 监听进程恢复,log-update reset previousOutput,并 reassert 模式(与 resize/unmount 共用逻辑)。
Ctrl+C / Ctrl+Z: exitOnCtrlC 控制;Unix 上 Ctrl+Z 可 SIGSTOP(SUPPORTS_SUSPEND)。
源码引用: src/ink/events/terminal-focus-event.ts · 第 1–5 行(共 20 行)
1| import { Event } from './event.js'
2|
3| export type TerminalFocusEventType = 'terminalfocus' | 'terminalblur'
4|
5| /**
源码引用: src/ink/hooks/use-terminal-focus.ts · 第 1–17 行(共 17 行)
1| import { useContext } from 'react'
2| import TerminalFocusContext from '../components/TerminalFocusContext.js'
3|
4| /**
5| * Hook to check if the terminal has focus.
6| *
7| * Uses DECSET 1004 focus reporting - the terminal sends escape sequences
8| * when it gains or loses focus. These are handled automatically
9| * by Ink and filtered from useInput.
10| *
11| * @returns true if the terminal is focused (or focus state is unknown)
12| */
13| export function useTerminalFocus(): boolean {
14| const { isTerminalFocused } = useContext(TerminalFocusContext)
15| return isTerminalFocused
16| }
17|
与 REPL 的接缝
REPL 不直接读 stdin;依赖 Ink App 泵。相关接缝:
- consumeEarlyInput:raw mode 前 bootstrap 缓冲的预输入
- onStdinResume:全屏下重进 alt-screen + 鼠标跟踪
- dispatchKeyboardEvent:与 useInput 并行,服务 Box 树
- selection 鼠标:App 收 SGR 鼠标 → Ink selection 状态 → onSelectionChange → scheduleRender
调试「按键无反应」:确认 isRawModeSupported、是否有 inactive useInput 抢焦点、tmux 是否剥离 MODIFY_OTHER_KEYS。
调试「粘贴变乱码」:查 EBP 是否启用、parser 是否停在 IN_PASTE、PASTE_TIMEOUT 是否过短。
源码目录
核心链:App.tsx → parse-keypress.ts → events/* → hooks/use-input.ts。resize 另见 ink.tsx handleResize。
本章小结与延伸
终端事件层保证字节流正确语义化并恢复终端模式。业务快捷键应优先 useInput;需要冒泡时用 Box onKeyDown。 继续学习: