first commit

This commit is contained in:
H
2026-04-03 13:01:19 +08:00
commit 538eced414
2575 changed files with 645911 additions and 0 deletions

View File

@@ -0,0 +1,303 @@
import { c as _c } from "react/compiler-runtime";
import React from 'react';
import Link from './components/Link.js';
import Text from './components/Text.js';
import type { Color } from './styles.js';
import { type NamedColor, Parser, type Color as TermioColor, type TextStyle } from './termio.js';
type Props = {
children: string;
/** 为 true 时,强制所有文本以 dim 样式渲染 */
dimColor?: boolean;
};
type SpanProps = {
color?: Color;
backgroundColor?: Color;
dim?: boolean;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
inverse?: boolean;
hyperlink?: string;
};
/**
* 解析 ANSI 转义码并使用 Text 组件渲染它们的组件。
*
* 当你有来自外部工具(如 cli-highlight的预格式化 ANSI 字符串,
* 需要在 Ink 中渲染时,使用此组件作为逃生舱。
*
* 被 memoized 以防止父组件更改但子字符串相同时重新渲染。
*/
export const Ansi = React.memo(function Ansi(t0) {
const $ = _c(12);
const {
children,
dimColor
} = t0;
if (typeof children !== "string") {
let t1;
if ($[0] !== children || $[1] !== dimColor) {
t1 = dimColor ? <Text dim={true}>{String(children)}</Text> : <Text>{String(children)}</Text>;
$[0] = children;
$[1] = dimColor;
$[2] = t1;
} else {
t1 = $[2];
}
return t1;
}
if (children === "") {
return null;
}
let t1;
let t2;
if ($[3] !== children || $[4] !== dimColor) {
t2 = Symbol.for("react.early_return_sentinel");
bb0: {
const spans = parseToSpans(children);
if (spans.length === 0) {
t2 = null;
break bb0;
}
if (spans.length === 1 && !hasAnyProps(spans[0].props)) {
t2 = dimColor ? <Text dim={true}>{spans[0].text}</Text> : <Text>{spans[0].text}</Text>;
break bb0;
}
let t3;
if ($[7] !== dimColor) {
t3 = (span, i) => {
const hyperlink = span.props.hyperlink;
if (dimColor) {
span.props.dim = true;
}
const hasTextProps = hasAnyTextProps(span.props);
if (hyperlink) {
return hasTextProps ? <Link key={i} url={hyperlink}><StyledText color={span.props.color} backgroundColor={span.props.backgroundColor} dim={span.props.dim} bold={span.props.bold} italic={span.props.italic} underline={span.props.underline} strikethrough={span.props.strikethrough} inverse={span.props.inverse}>{span.text}</StyledText></Link> : <Link key={i} url={hyperlink}>{span.text}</Link>;
}
return hasTextProps ? <StyledText key={i} color={span.props.color} backgroundColor={span.props.backgroundColor} dim={span.props.dim} bold={span.props.bold} italic={span.props.italic} underline={span.props.underline} strikethrough={span.props.strikethrough} inverse={span.props.inverse}>{span.text}</StyledText> : span.text;
};
$[7] = dimColor;
$[8] = t3;
} else {
t3 = $[8];
}
t1 = spans.map(t3);
}
$[3] = children;
$[4] = dimColor;
$[5] = t1;
$[6] = t2;
} else {
t1 = $[5];
t2 = $[6];
}
if (t2 !== Symbol.for("react.early_return_sentinel")) {
return t2;
}
const content = t1;
let t3;
if ($[9] !== content || $[10] !== dimColor) {
t3 = dimColor ? <Text dim={true}>{content}</Text> : <Text>{content}</Text>;
$[9] = content;
$[10] = dimColor;
$[11] = t3;
} else {
t3 = $[11];
}
return t3;
});
type Span = {
text: string;
props: SpanProps;
};
/**
* 使用 termio 解析器将 ANSI 字符串解析为 spans。
*/
function parseToSpans(input: string): Span[] {
const parser = new Parser();
const actions = parser.feed(input);
const spans: Span[] = [];
let currentHyperlink: string | undefined;
for (const action of actions) {
if (action.type === 'link') {
if (action.action.type === 'start') {
currentHyperlink = action.action.url;
} else {
currentHyperlink = undefined;
}
continue;
}
if (action.type === 'text') {
const text = action.graphemes.map(g => g.value).join('');
if (!text) continue;
const props = textStyleToSpanProps(action.style);
if (currentHyperlink) {
props.hyperlink = currentHyperlink;
}
// 如果 props 匹配,尝试与前一个 span 合并
const lastSpan = spans[spans.length - 1];
if (lastSpan && propsEqual(lastSpan.props, props)) {
lastSpan.text += text;
} else {
spans.push({
text,
props
});
}
}
}
return spans;
}
/**
* 将 termio 的 TextStyle 转换为 SpanProps。
*/
function textStyleToSpanProps(style: TextStyle): SpanProps {
const props: SpanProps = {};
if (style.bold) props.bold = true;
if (style.dim) props.dim = true;
if (style.italic) props.italic = true;
if (style.underline !== 'none') props.underline = true;
if (style.strikethrough) props.strikethrough = true;
if (style.inverse) props.inverse = true;
const fgColor = colorToString(style.fg);
if (fgColor) props.color = fgColor;
const bgColor = colorToString(style.bg);
if (bgColor) props.backgroundColor = bgColor;
return props;
}
/**
* 将 termio 命名颜色映射到 ansi: 格式
*/
const NAMED_COLOR_MAP: Record<NamedColor, string> = {
black: 'ansi:black',
red: 'ansi:red',
green: 'ansi:green',
yellow: 'ansi:yellow',
blue: 'ansi:blue',
magenta: 'ansi:magenta',
cyan: 'ansi:cyan',
white: 'ansi:white',
brightBlack: 'ansi:blackBright',
brightRed: 'ansi:redBright',
brightGreen: 'ansi:greenBright',
brightYellow: 'ansi:yellowBright',
brightBlue: 'ansi:blueBright',
brightMagenta: 'ansi:magentaBright',
brightCyan: 'ansi:cyanBright',
brightWhite: 'ansi:whiteBright'
};
/**
* 将 termio 的 Color 转换为 Ink 使用的字符串格式。
*/
function colorToString(color: TermioColor): Color | undefined {
switch (color.type) {
case 'named':
return NAMED_COLOR_MAP[color.name] as Color;
case 'indexed':
return `ansi256(${color.index})` as Color;
case 'rgb':
return `rgb(${color.r},${color.g},${color.b})` as Color;
case 'default':
return undefined;
}
}
/**
* 检查两个 SpanProps 是否相等以便合并。
*/
function propsEqual(a: SpanProps, b: SpanProps): boolean {
return a.color === b.color && a.backgroundColor === b.backgroundColor && a.bold === b.bold && a.dim === b.dim && a.italic === b.italic && a.underline === b.underline && a.strikethrough === b.strikethrough && a.inverse === b.inverse && a.hyperlink === b.hyperlink;
}
function hasAnyProps(props: SpanProps): boolean {
return props.color !== undefined || props.backgroundColor !== undefined || props.dim === true || props.bold === true || props.italic === true || props.underline === true || props.strikethrough === true || props.inverse === true || props.hyperlink !== undefined;
}
function hasAnyTextProps(props: SpanProps): boolean {
return props.color !== undefined || props.backgroundColor !== undefined || props.dim === true || props.bold === true || props.italic === true || props.underline === true || props.strikethrough === true || props.inverse === true;
}
/**
* 不带权重bold/dim的文本样式 props - 这些单独处理
*/
type BaseTextStyleProps = {
color?: Color;
backgroundColor?: Color;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
inverse?: boolean;
};
/**
* 处理 Text 的 bold/dim 互斥性的包装组件
*/
function StyledText(t0) {
const $ = _c(14);
let bold;
let children;
let dim;
let rest;
if ($[0] !== t0) {
({
bold,
dim,
children,
...rest
} = t0);
$[0] = t0;
$[1] = bold;
$[2] = children;
$[3] = dim;
$[4] = rest;
} else {
bold = $[1];
children = $[2];
dim = $[3];
rest = $[4];
}
// 当两者都设置时dim 优先于 bold终端将它们视为互斥
if (dim) {
let t1;
if ($[5] !== children || $[6] !== rest) {
t1 = <Text {...rest} dim={true}>{children}</Text>;
$[5] = children;
$[6] = rest;
$[7] = t1;
} else {
t1 = $[7];
}
return t1;
}
if (bold) {
let t1;
if ($[8] !== children || $[9] !== rest) {
t1 = <Text {...rest} bold={true}>{children}</Text>;
$[8] = children;
$[9] = rest;
$[10] = t1;
} else {
t1 = $[10];
}
return t1;
}
let t1;
if ($[11] !== children || $[12] !== rest) {
t1 = <Text {...rest}>{children}</Text>;
$[11] = children;
$[12] = rest;
$[13] = t1;
} else {
t1 = $[13];
}
return t1;
}

View File

@@ -0,0 +1,138 @@
/**
* Bidirectional text reordering for terminal rendering.
*
* Terminals on Windows do not implement the Unicode Bidi Algorithm,
* so RTL text (Hebrew, Arabic, etc.) appears reversed. This module
* applies the bidi algorithm to reorder ClusteredChar arrays from
* logical order to visual order before Ink's LTR cell placement loop.
*
* On macOS terminals (Terminal.app, iTerm2) bidi works natively.
* Windows Terminal (including WSL) does not implement bidi
* (https://github.com/microsoft/terminal/issues/538).
*
* Detection: Windows Terminal sets WT_SESSION; native Windows cmd/conhost
* also lacks bidi. We enable bidi reordering when running on Windows or
* inside Windows Terminal (covers WSL).
*/
import bidiFactory from 'bidi-js'
type ClusteredChar = {
value: string
width: number
styleId: number
hyperlink: string | undefined
}
let bidiInstance: ReturnType<typeof bidiFactory> | undefined
let needsSoftwareBidi: boolean | undefined
function needsBidi(): boolean {
if (needsSoftwareBidi === undefined) {
needsSoftwareBidi =
process.platform === 'win32' ||
typeof process.env['WT_SESSION'] === 'string' || // WSL in Windows Terminal
process.env['TERM_PROGRAM'] === 'vscode' // VS Code integrated terminal (xterm.js)
}
return needsSoftwareBidi
}
function getBidi() {
if (!bidiInstance) {
bidiInstance = bidiFactory()
}
return bidiInstance
}
/**
* 使用 Unicode 双向算法将 ClusteredChar 数组从逻辑顺序重新排序为视觉顺序。
* 在缺乏原生 bidi 支持的终端Windows Terminal、conhost、WSL上生效。
*
* 在支持 bidi 的终端上返回相同数组(无操作)。
*/
export function reorderBidi(characters: ClusteredChar[]): ClusteredChar[] {
if (!needsBidi() || characters.length === 0) {
return characters
}
// 从 clustered chars 构建纯字符串以通过 bidi 处理
const plainText = characters.map(c => c.value).join('')
// 检查是否有 RTL 字符——如果是纯 LTR 则跳过 bidi
if (!hasRTLCharacters(plainText)) {
return characters
}
const bidi = getBidi()
const { levels } = bidi.getEmbeddingLevels(plainText, 'auto')
// 将 bidi 级别映射回 ClusteredChar 索引。
// 每个 ClusteredChar 在连接后的字符串中可能是多个代码单元。
const charLevels: number[] = []
let offset = 0
for (let i = 0; i < characters.length; i++) {
charLevels.push(levels[offset]!)
offset += characters[i]!.value.length
}
// 从 bidi-js 获取重排序段,但需要在 ClusteredChar 级别工作,
// 而不是字符串级别。我们实现标准的 bidi 重排序:
// 找到最大级别,然后对于从 max 到 1 的每个级别,
// 反转所有连续的大于等于该级别的运行。
const reordered = [...characters]
const maxLevel = Math.max(...charLevels)
for (let level = maxLevel; level >= 1; level--) {
let i = 0
while (i < reordered.length) {
if (charLevels[i]! >= level) {
// 找到此运行的结束位置
let j = i + 1
while (j < reordered.length && charLevels[j]! >= level) {
j++
}
// 反转两个数组中的运行
reverseRange(reordered, i, j - 1)
reverseRangeNumbers(charLevels, i, j - 1)
i = j
} else {
i++
}
}
}
return reordered
}
function reverseRange<T>(arr: T[], start: number, end: number): void {
while (start < end) {
const temp = arr[start]!
arr[start] = arr[end]!
arr[end] = temp
start++
end--
}
}
function reverseRangeNumbers(arr: number[], start: number, end: number): void {
while (start < end) {
const temp = arr[start]!
arr[start] = arr[end]!
arr[end] = temp
start++
end--
}
}
/**
* 快速检查 RTL 字符(希伯来语、阿拉伯语及相关脚本)。
* 避免在纯 LTR 文本上运行完整的 bidi 算法。
*/
function hasRTLCharacters(text: string): boolean {
// Hebrew: U+0590-U+05FF, U+FB1D-U+FB4F
// Arabic: U+0600-U+06FF, U+0750-U+077F, U+08A0-U+08FF, U+FB50-U+FDFF, U+FE70-U+FEFF
// Thaana: U+0780-U+07BF
// Syriac: U+0700-U+074F
return /[\u0590-\u05FF\uFB1D-\uFB4F\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\u0780-\u07BF\u0700-\u074F]/u.test(
text,
)
}

View File

@@ -0,0 +1,74 @@
/**
* 支持滚动缓冲清除的跨平台终端清除。
* 检测支持 ESC[3J 清除滚动缓冲区的现代终端。
*/
import {
CURSOR_HOME,
csi,
ERASE_SCREEN,
ERASE_SCROLLBACK,
} from './termio/csi.js'
// HVP (Horizontal Vertical Position) - 传统 Windows 光标归位
const CURSOR_HOME_WINDOWS = csi(0, 'f')
function isWindowsTerminal(): boolean {
return process.platform === 'win32' && !!process.env.WT_SESSION
}
function isMintty(): boolean {
// mintty 3.1.5+ 将 TERM_PROGRAM 设置为 'mintty'
if (process.env.TERM_PROGRAM === 'mintty') {
return true
}
// GitBash/MSYS2/MINGW 使用 mintty 并设置 MSYSTEM
if (process.platform === 'win32' && process.env.MSYSTEM) {
return true
}
return false
}
function isModernWindowsTerminal(): boolean {
// Windows Terminal 设置 WT_SESSION 环境变量
if (isWindowsTerminal()) {
return true
}
// Windows 上支持 ConPTY 的 VS Code 集成终端
if (
process.platform === 'win32' &&
process.env.TERM_PROGRAM === 'vscode' &&
process.env.TERM_PROGRAM_VERSION
) {
return true
}
// mintty (GitBash/MSYS2/Cygwin) 支持现代转义序列
if (isMintty()) {
return true
}
return false
}
/**
* 返回清除终端(包括滚动缓冲区)的 ANSI 转义序列。
* 自动检测终端能力。
*/
export function getClearTerminalSequence(): string {
if (process.platform === 'win32') {
if (isModernWindowsTerminal()) {
return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME
} else {
// 传统 Windows 控制台 - 无法清除滚动缓冲区
return ERASE_SCREEN + CURSOR_HOME_WINDOWS
}
}
return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME
}
/**
* 清除终端屏幕。在支持的终端上,也清除滚动缓冲区。
*/
export const clearTerminal = getClearTerminalSequence()

View File

@@ -0,0 +1,228 @@
import chalk from 'chalk'
import type { Color, TextStyles } from './styles.js'
/**
* xterm.jsVS Code、Cursor、code-server、Coder自 2017 年以来支持真彩色,
* 但 code-server/Coder 容器通常不设置 COLORTERM=truecolor。
* chalk 的 supports-color 不识别 TERM_PROGRAM=vscode它只知道 iTerm.app/Apple_Terminal
* 所以它落入 -256color 正则表达式 → level 2。在 level 2chalk.rgb()
* 降级到最近的 6×6×6 立方体颜色rgb(215,119,87)Claude
* 橙色)→ idx 174 rgb(215,135,135) — 褪色的三文鱼色。
*
* 在 level === 2不是 < 3上门控以尊重 NO_COLOR / FORCE_COLOR=0 —
* 这些产生 level 0是明确的"不要颜色"请求。桌面 VS
* Code 本身设置 COLORTERM=truecolor所以这在那里是空操作已经是 3
*
* 必须在 tmux 限制之前运行 — 如果 tmux 在 VS Code
* 终端内运行tmux 的穿透限制获胜,我们想要 level 2。
*/
function boostChalkLevelForXtermJs(): boolean {
if (process.env.TERM_PROGRAM === 'vscode' && chalk.level === 2) {
chalk.level = 3
return true
}
return false
}
/**
* tmux 将真彩色 SGR (\e[48;2;r;g;bm) 正确解析到其单元格缓冲区,
* 但其客户端发射器仅在外部终端广告 Tc/RGB 能力(通过 terminal-overrides
* 向外部终端重新发射真彩色。默认 tmux 配置不设置这个,
* 所以 tmux 向 iTerm2/等发射单元格时不带 bg 序列 — 外部终端的缓冲区
* bg=default → 深色配置下为黑色。在 level 2 限制使 chalk 发出 256 色
* (\e[48;5;Nm)tmux 干净地穿透。grey93 (255) 在视觉上与
* rgb(240,240,240) 相同。
*
* 已经设置 `terminal-overrides ,*:Tc` 的用户得到了技术上不必要的
* 降级,但视觉差异是不可察觉的。查询
* `tmux show -gv terminal-overrides` 来检测这个会在启动时增加一个子进程
* — 不值得。
*
* $TMUX 是由 tmux 本身设置的 pty 生命周期环境变量;它从不来自
* globalSettings.env所以在这里读取是正确的。chalk 是一个单例,所以
* 这限制了所有真彩色输出fg+bg+hex贯穿整个应用。
*/
function clampChalkLevelForTmux(): boolean {
// bg.ts 在附加之前设置 terminal-overrides :Tc所以真彩色穿透
// — 跳过限制。对于正确配置了 tmux 的人是通用的逃生舱。
if (process.env.CLAUDE_CODE_TMUX_TRUECOLOR) return false
if (process.env.TMUX && chalk.level > 2) {
chalk.level = 2
return true
}
return false
}
// 在模块加载时计算一次 — 终端/tmux 环境在会话期间不会改变。
// 顺序很重要:首先提升,以便如果 tmux 在 VS Code 终端内运行,
// tmux 限制可以重新限制。导出用于调试 — 如果未使用则被 tree-shaken。
export const CHALK_BOOSTED_FOR_XTERMJS = boostChalkLevelForXtermJs()
export const CHALK_CLAMPED_FOR_TMUX = clampChalkLevelForTmux()
export type ColorType = 'foreground' | 'background'
const RGB_REGEX = /^rgb\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/
const ANSI_REGEX = /^ansi256\(\s?(\d+)\s?\)$/
export const colorize = (
str: string,
color: string | undefined,
type: ColorType,
): string => {
if (!color) {
return str
}
if (color.startsWith('ansi:')) {
const value = color.substring('ansi:'.length)
switch (value) {
case 'black':
return type === 'foreground' ? chalk.black(str) : chalk.bgBlack(str)
case 'red':
return type === 'foreground' ? chalk.red(str) : chalk.bgRed(str)
case 'green':
return type === 'foreground' ? chalk.green(str) : chalk.bgGreen(str)
case 'yellow':
return type === 'foreground' ? chalk.yellow(str) : chalk.bgYellow(str)
case 'blue':
return type === 'foreground' ? chalk.blue(str) : chalk.bgBlue(str)
case 'magenta':
return type === 'foreground' ? chalk.magenta(str) : chalk.bgMagenta(str)
case 'cyan':
return type === 'foreground' ? chalk.cyan(str) : chalk.bgCyan(str)
case 'white':
return type === 'foreground' ? chalk.white(str) : chalk.bgWhite(str)
case 'blackBright':
return type === 'foreground'
? chalk.blackBright(str)
: chalk.bgBlackBright(str)
case 'redBright':
return type === 'foreground'
? chalk.redBright(str)
: chalk.bgRedBright(str)
case 'greenBright':
return type === 'foreground'
? chalk.greenBright(str)
: chalk.bgGreenBright(str)
case 'yellowBright':
return type === 'foreground'
? chalk.yellowBright(str)
: chalk.bgYellowBright(str)
case 'blueBright':
return type === 'foreground'
? chalk.blueBright(str)
: chalk.bgBlueBright(str)
case 'magentaBright':
return type === 'foreground'
? chalk.magentaBright(str)
: chalk.bgMagentaBright(str)
case 'cyanBright':
return type === 'foreground'
? chalk.cyanBright(str)
: chalk.bgCyanBright(str)
case 'whiteBright':
return type === 'foreground'
? chalk.whiteBright(str)
: chalk.bgWhiteBright(str)
}
}
if (color.startsWith('#')) {
return type === 'foreground'
? chalk.hex(color)(str)
: chalk.bgHex(color)(str)
}
if (color.startsWith('ansi256')) {
const matches = ANSI_REGEX.exec(color)
if (!matches) {
return str
}
const value = Number(matches[1])
return type === 'foreground'
? chalk.ansi256(value)(str)
: chalk.bgAnsi256(value)(str)
}
if (color.startsWith('rgb')) {
const matches = RGB_REGEX.exec(color)
if (!matches) {
return str
}
const firstValue = Number(matches[1])
const secondValue = Number(matches[2])
const thirdValue = Number(matches[3])
return type === 'foreground'
? chalk.rgb(firstValue, secondValue, thirdValue)(str)
: chalk.bgRgb(firstValue, secondValue, thirdValue)(str)
}
return str
}
/**
* 使用 chalk 将 TextStyles 应用到字符串。
* 这是解析 ANSI 代码的逆过程 — 我们从结构化样式生成它们。
* 主题解析发生在组件层,而不是这里。
*/
export function applyTextStyles(text: string, styles: TextStyles): string {
let result = text
// 按期望嵌套的逆序应用样式。
// chalk 包装文本,所以后面的调用变成外层包装。
// 期望顺序(从外到内):
// background > foreground > text modifiers
// 所以我们首先应用text modifiers然后 foreground然后 background 最后。
if (styles.inverse) {
result = chalk.inverse(result)
}
if (styles.strikethrough) {
result = chalk.strikethrough(result)
}
if (styles.underline) {
result = chalk.underline(result)
}
if (styles.italic) {
result = chalk.italic(result)
}
if (styles.bold) {
result = chalk.bold(result)
}
if (styles.dim) {
result = chalk.dim(result)
}
if (styles.color) {
// color 现在总是原始颜色值(主题解析发生在组件层)
result = colorize(result, styles.color, 'foreground')
}
if (styles.backgroundColor) {
// backgroundColor 现在总是原始颜色值
result = colorize(result, styles.backgroundColor, 'background')
}
return result
}
/**
* 将原始颜色值应用到文本。
* 主题解析应该发生在组件层,而不是这里。
*/
export function applyColor(text: string, color: Color | undefined): string {
if (!color) {
return text
}
return colorize(text, color, 'foreground')
}

View File

@@ -0,0 +1,82 @@
import React, {
type PropsWithChildren,
useContext,
useInsertionEffect,
} from 'react'
import instances from '../instances.js'
import { DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN } from '../termio/dec.js'
import { TerminalWriteContext } from '../useTerminalNotification.js'
import Box from './Box.js'
import { TerminalSizeContext } from './TerminalSizeContext.js'
type Props = PropsWithChildren<{
/** 启用 SGR 鼠标跟踪(滚轮 + 点击/拖动)。默认 true。 */
mouseTracking?: boolean
}>
/**
* 在终端的备用屏幕缓冲区中运行子节点,约束到
* 视口高度。挂载期间:
*
* - 进入备用屏幕DEC 1049清除它将光标复位
* - 将其自身高度约束为终端行数,因此溢出必须
* 通过 `overflow: scroll` / flexbox 处理(无原生滚动缓冲区)
* - 可选启用 SGR 鼠标跟踪(滚轮 + 点击/拖动)— 事件
* 作为 `ParsedKey`(滚轮)浮出水面并更新 Ink 实例的
* 选择状态(点击/拖动)
*
* 卸载时,禁用鼠标跟踪并退出备用屏幕,恢复
* 主屏幕内容。可安全用于 ctrl-o 转录覆盖
* 和类似的临时全屏视图 — 主屏幕被保留。
*
* 通过 `setAltScreenActive()` 通知 Ink 实例,以便渲染器
* 将光标保持在视口内(防止光标恢复 LF
* 滚动内容)并且以便信号退出清理可以退出备用屏幕
* 如果组件自身的卸载没有运行。
*/
export function AlternateScreen({
children,
mouseTracking = true,
}: Props): React.ReactNode {
const size = useContext(TerminalSizeContext)
const writeRaw = useContext(TerminalWriteContext)
// useInsertionEffect不是 useLayoutEffectreact-reconciler 在
// mutation 和 layout commit phases 之间调用 resetAfterCommit并且
// Ink 的 resetAfterCommit 在 onRender 上触发。使用 useLayoutEffect那个
// 第一个 onRender 触发 BEFORE this effect — 写入一个完整帧到
// 主屏幕 with altScreen=false。那个帧在进入备用屏幕时被保留
// 并在退出时作为破碎视图恢复。Insertion
// effects 在 mutation phase 期间触发,在 resetAfterCommit 之前,所以
// ENTER_ALT_SCREEN 在第一帧之前到达终端。
// 清理时间不变both insertion and layout effect cleanup
// 在 unmount 的 mutation phase 运行,在 resetAfterCommit 之前。
useInsertionEffect(() => {
const ink = instances.get(process.stdout)
if (!writeRaw) return
writeRaw(
ENTER_ALT_SCREEN +
'\x1b[2J\x1b[H' +
(mouseTracking ? ENABLE_MOUSE_TRACKING : ''),
)
ink?.setAltScreenActive(true, mouseTracking)
return () => {
ink?.setAltScreenActive(false)
ink?.clearTextSelection()
writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN)
}
}, [writeRaw, mouseTracking])
return (
<Box
flexDirection="column"
height={size?.rows ?? 24}
width="100%"
flexShrink={0}
>
{children}
</Box>
)
}

View File

@@ -0,0 +1,772 @@
import React, { PureComponent, type ReactNode } from 'react';
import { updateLastInteractionTime } from '../../bootstrap/state.js';
import { logForDebugging } from '../../utils/debug.js';
import { stopCapturingEarlyInput } from '../../utils/earlyInput.js';
import { isEnvTruthy } from '../../utils/envUtils.js';
import { isMouseClicksDisabled } from '../../utils/fullscreen.js';
import { logError } from '../../utils/log.js';
import { EventEmitter } from '../events/emitter.js';
import { InputEvent } from '../events/input-event.js';
import { TerminalFocusEvent } from '../events/terminal-focus-event.js';
import { INITIAL_STATE, type ParsedInput, type ParsedKey, type ParsedMouse, parseMultipleKeypresses } from '../parse-keypress.js';
import reconciler from '../reconciler.js';
import { finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js';
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js';
import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js';
import { TerminalQuerier, xtversion } from '../terminal-querier.js';
import { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS, FOCUS_IN, FOCUS_OUT } from '../termio/csi.js';
import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, HIDE_CURSOR, SHOW_CURSOR } from '../termio/dec.js';
import AppContext from './AppContext.js';
import { ClockProvider } from './ClockContext.js';
import CursorDeclarationContext, { type CursorDeclarationSetter } from './CursorDeclarationContext.js';
import ErrorOverview from './ErrorOverview.js';
import StdinContext from './StdinContext.js';
import { TerminalFocusProvider } from './TerminalFocusContext.js';
import { TerminalSizeContext } from './TerminalSizeContext.js';
/**
* 支持 Unix 风格进程挂起SIGSTOP/SIGCONT的平台
*/
const SUPPORTS_SUSPEND = process.platform !== 'win32';
/**
* 在这么多毫秒的 stdin 静默之后,下一个 chunk 会触发
* 终端模式重新断言(鼠标跟踪)。捕获 tmux detach→attach、
* ssh 重连和笔记本唤醒——终端会重置 DEC 私有模式,
* 但没有信号到达我们。5s 远高于正常按键间隔,
* 但足够短以至于重新附加后的第一次滚动有效。
*/
const STDIN_RESUME_GAP_MS = 5000;
type Props = {
readonly children: ReactNode;
readonly stdin: NodeJS.ReadStream;
readonly stdout: NodeJS.WriteStream;
readonly stderr: NodeJS.WriteStream;
readonly exitOnCtrlC: boolean;
readonly onExit: (error?: Error) => void;
readonly terminalColumns: number;
readonly terminalRows: number;
/**
* 文本选择状态。App 直接从鼠标事件修改此状态,
* 并调用 onSelectionChange 触发重绘。鼠标事件仅在
* <AlternateScreen>(或类似组件)启用鼠标跟踪时到达,
* 所以处理程序始终连接但处于休眠状态,直到跟踪开启。
*/
readonly selection: SelectionState;
readonly onSelectionChange: () => void;
/**
* 在 (col, row) 分发点击——命中测试 DOM 树并向上冒泡
* onClick 处理程序。如果 DOM 处理程序消耗了点击则返回 true。
* 在全屏模式外无操作(返回 falseInk.dispatchClick
* 根据 altScreenActive 判断)。
*/
readonly onClickAt: (col: number, row: number) => boolean;
/**
* 当指针在 DOM 元素上移动时分发悬停onMouseEnter/onMouseLeave
* 为无按钮按住时的 mode-1003 运动事件调用。
* 在全屏模式外无操作Ink.dispatchHover 根据 altScreenActive 判断)。
*/
readonly onHoverAt: (col: number, row: number) => void;
/**
* 在点击时同步查找 (col, row) 处的 OSC 8 超链接。
* 返回 URL 或 undefined。浏览器打开会延迟到
* MULTI_CLICK_TIMEOUT_MS 以便双击可以取消它。
*/
readonly getHyperlinkAt: (col: number, row: number) => string | undefined;
/**
* 在浏览器中打开超链接 URL。定时器触发后调用。
*/
readonly onOpenHyperlink: (url: string) => void;
/**
* 在 (col, row) 双击/三击按下时调用。count=2 选择
* 光标下的单词count=3 选择整行。Ink 读取
* 屏幕缓冲区以查找单词/行边界并修改选择,
* 设置 isDragging=true 以便后续拖动按单词/行扩展。
*/
readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void;
/**
* 拖动时调用。模式感知char 模式更新到精确单元格;
* word/line 模式吸附到单词/行边界。需要
* 屏幕缓冲区访问(单词边界),所以位于 Ink 而非此处。
*/
readonly onSelectionDrag: (col: number, row: number) => void;
/**
* 当 stdin 数据在 >STDIN_RESUME_GAP_MS 间隔后到达时调用。
* Ink 重新断言终端模式:扩展键报告,以及(在全屏时)
* 重新进入备用屏幕 + 鼠标跟踪。对终端来说是幂等的。
* 可选,这样 testing.tsx 不需要 stub 它。
*/
readonly onStdinResume?: () => void;
/**
* 接收 useDeclaredCursor 声明的本机光标位置,
* 以便 ink.tsx 可以在每一帧后将终端光标停在那里。
* 启用输入插入符处的 IME 组合,并让屏幕阅读器/
* 放大镜跟踪输入。可选,这样 testing.tsx 不需要 stub 它。
*/
readonly onCursorDeclaration?: CursorDeclarationSetter;
/**
* 通过 DOM 树分发键盘事件。为每个解析的键以及
* 遗留的 EventEmitter 路径调用。
*/
readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void;
};
/**
* 多击检测阈值。500ms 是 macOS 默认值;
* 小位置容差允许点击之间的触控板抖动。
*/
const MULTI_CLICK_TIMEOUT_MS = 500;
const MULTI_CLICK_DISTANCE = 1;
type State = {
readonly error?: Error;
};
/**
* 所有 Ink 应用的根组件。
* 它渲染 stdin 和 stdout 上下文,以便子组件可以在需要时访问它们。
* 它还处理 Ctrl+C 退出和光标可见性。
*/
export default class App extends PureComponent<Props, State> {
static displayName = 'InternalApp';
static getDerivedStateFromError(error: Error) {
return {
error
};
}
override state = {
error: undefined
};
/**
* 计数启用原始模式的组件数量,以避免在所有组件不再需要它之前
* 禁用原始模式
*/
rawModeEnabledCount = 0;
internal_eventEmitter = new EventEmitter();
keyParseState = INITIAL_STATE;
/**
* 刷新不完整转义序列的定时器
*/
incompleteEscapeTimer: NodeJS.Timeout | null = null;
/**
* 不完整序列的超时持续时间(毫秒)
*/
readonly NORMAL_TIMEOUT = 50; // 常规转义序列的短超时
readonly PASTE_TIMEOUT = 500; // 粘贴操作的较长超时
/**
* 终端查询/响应分发。响应到达 stdin由 parse-keypress 解析)
* 并路由到待处理的 promise 解析器。
*/
querier = new TerminalQuerier(this.props.stdout);
/**
* 双击/三击文本选择的多击跟踪。上一次
* 点击在 MULTI_CLICK_TIMEOUT_MS 和 MULTI_CLICK_DISTANCE 内的点击
* 递增 clickCount否则重置为 1。
*/
lastClickTime = 0;
lastClickCol = -1;
lastClickRow = -1;
clickCount = 0;
/**
* 延迟的超链接打开定时器——如果在 MULTI_CLICK_TIMEOUT_MS 内
* 有第二次点击到达则取消(所以双击超链接选择
* 单词而不是同时打开浏览器。DOM onClick 分发不是
* 延迟的——它从 onClickAt 返回 true 并跳过此定时器。
*/
pendingHyperlinkTimer: ReturnType<typeof setTimeout> | null = null;
/**
* 上一个 mode-1003 运动位置。终端已经去重到单元格
* 粒度,但这也让我们完全跳过重复事件
*(拖动然后释放在同一单元格等)的 dispatchHover。
*/
lastHoverCol = -1;
lastHoverRow = -1;
/**
* 上一个 stdin chunk 的时间戳。用于检测长间隔tmux 附加、
* ssh 重连、笔记本唤醒)并触发终端模式重新断言。
* 初始化为现在以避免启动时误触发。
*/
lastStdinTime = Date.now();
/**
* 确定在提供的 stdin 上是否支持 TTY
*/
isRawModeSupported(): boolean {
return this.props.stdin.isTTY;
}
override render() {
return <TerminalSizeContext.Provider value={{
columns: this.props.terminalColumns,
rows: this.props.terminalRows
}}>
<AppContext.Provider value={{
exit: this.handleExit
}}>
<StdinContext.Provider value={{
stdin: this.props.stdin,
setRawMode: this.handleSetRawMode,
isRawModeSupported: this.isRawModeSupported(),
internal_exitOnCtrlC: this.props.exitOnCtrlC,
internal_eventEmitter: this.internal_eventEmitter,
internal_querier: this.querier
}}>
<TerminalFocusProvider>
<ClockProvider>
<CursorDeclarationContext.Provider value={this.props.onCursorDeclaration ?? (() => {})}>
{this.state.error ? <ErrorOverview error={this.state.error as Error} /> : this.props.children}
</CursorDeclarationContext.Provider>
</ClockProvider>
</TerminalFocusProvider>
</StdinContext.Provider>
</AppContext.Provider>
</TerminalSizeContext.Provider>;
}
override componentDidMount() {
// 在无障碍模式下,为屏幕放大镜和其他工具保持本机光标可见
if (this.props.stdout.isTTY && !isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) {
this.props.stdout.write(HIDE_CURSOR);
}
}
override componentWillUnmount() {
if (this.props.stdout.isTTY) {
this.props.stdout.write(SHOW_CURSOR);
}
// 清除任何待处理的定时器
if (this.incompleteEscapeTimer) {
clearTimeout(this.incompleteEscapeTimer);
this.incompleteEscapeTimer = null;
}
if (this.pendingHyperlinkTimer) {
clearTimeout(this.pendingHyperlinkTimer);
this.pendingHyperlinkTimer = null;
}
// 忽略在无法调用 setRawMode 的句柄上调用 setRawMode
if (this.isRawModeSupported()) {
this.handleSetRawMode(false);
}
}
override componentDidCatch(error: Error) {
this.handleExit(error);
}
handleSetRawMode = (isEnabled: boolean): void => {
const {
stdin
} = this.props;
if (!this.isRawModeSupported()) {
if (stdin === process.stdin) {
throw new Error('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');
} else {
throw new Error('Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported');
}
}
stdin.setEncoding('utf8');
if (isEnabled) {
// 确保原始模式只启用一次
if (this.rawModeEnabledCount === 0) {
// 在添加我们自己的 readable 处理程序之前停止早期输入捕获。
// 两者都使用相同的 stdin 'readable' + read() 模式,所以它们不能
// 共存——我们的处理程序会在 Ink 看到之前耗尽 stdin。
// 缓冲的文本通过 consumeEarlyInput() 为 REPL.tsx 保留。
stopCapturingEarlyInput();
stdin.ref();
stdin.setRawMode(true);
stdin.addListener('readable', this.handleReadable);
// 启用带括号粘贴模式
this.props.stdout.write(EBP);
// 启用终端焦点报告 (DECSET 1004)
this.props.stdout.write(EFE);
// 启用扩展键报告,以便 ctrl+shift+<letter> 可与
// ctrl+<letter> 区分。我们同时写入 kitty 栈
// push (CSI >1u) 和 xterm modifyOtherKeys level 2 (CSI >4;2m)——
// 终端实现它们认可的任何一个tmux 只接受后者)。
if (supportsExtendedKeys()) {
this.props.stdout.write(ENABLE_KITTY_KEYBOARD);
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS);
}
// 探测终端身份。XTVERSION 通过 SSH查询/回复通过
// pty不像 TERM_PROGRAM。用于在没有环境变量时
// 检测滚轮滚动基础。火后不理DA1
// 边界限制往返如果终端忽略查询flush() 仍然解析,
// name 保持 undefined。延迟到下一个 tick
// 以便在当前同步
// 初始化序列完成后触发——避免与可能发生在同一渲染周期中的
// alt-screen/mouse tracking 启用写入交织。
setImmediate(() => {
void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => {
if (r) {
setXtversionName(r.name);
logForDebugging(`XTVERSION: terminal identified as "${r.name}"`);
} else {
logForDebugging('XTVERSION: no reply (terminal ignored query)');
}
});
});
}
this.rawModeEnabledCount++;
return;
}
// 仅当没有剩余使用它的组件时禁用原始模式
if (--this.rawModeEnabledCount === 0) {
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS);
this.props.stdout.write(DISABLE_KITTY_KEYBOARD);
// 禁用终端焦点报告 (DECSET 1004)
this.props.stdout.write(DFE);
// 禁用带括号粘贴模式
this.props.stdout.write(DBP);
stdin.setRawMode(false);
stdin.removeListener('readable', this.handleReadable);
stdin.unref();
}
};
/**
* 刷新不完整转义序列的帮助器
*/
flushIncomplete = (): void => {
// 清除定时器引用
this.incompleteEscapeTimer = null;
// 仅在我们有不完整序列时继续
if (!this.keyParseState.incomplete) return;
// 全屏:如果 stdin 有数据等待,它几乎肯定是
// 缓冲序列的延续(例如,在单独的 ESC 之后的 `[<64;74;16M`)。
// Node 的事件循环在 poll 阶段之前运行定时器阶段,
// 所以当重渲染阻塞循环超过 50ms 时,此定时器
// 在排队等待的 readable 事件之前触发,即使字节已经
// 缓冲。重新设置定时器而不是刷新handleReadable 下次
// 会排出 stdin 并清除此定时器。防止虚假的
// Escape 键和丢失的滚动事件。
if (this.props.stdin.readableLength > 0) {
this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.NORMAL_TIMEOUT);
return;
}
// 作为刷新操作处理不完整序列input=null
// 这重用所有现有解析逻辑
this.processInput(null);
};
/**
* 通过解析器处理输入并处理结果
*/
processInput = (input: string | Buffer | null): void => {
// 使用我们的状态机解析输入
const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input);
this.keyParseState = newState;
// 在单个 discreteUpdates 调用中处理所有键,以防止
// 当许多键同时到达时出现"最大更新深度超出"错误
//(例如,粘贴操作或快速按住键)。
// 这在同一个高优先级更新上下文中批处理来自 handleInput 和
// 所有 useInput 监听器的所有状态更新。
if (keys.length > 0) {
reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined);
}
// 如果有不完整的转义序列,设置定时器刷新它们
if (this.keyParseState.incomplete) {
// 首先取消任何现有的定时器
if (this.incompleteEscapeTimer) {
clearTimeout(this.incompleteEscapeTimer);
}
this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.keyParseState.mode === 'IN_PASTE' ? this.PASTE_TIMEOUT : this.NORMAL_TIMEOUT);
}
};
handleReadable = (): void => {
// 检测长的 stdin 间隔tmux 附加、ssh 重连、笔记本唤醒)。
// 终端可能已重置 DEC 私有模式;重新断言鼠标
// 跟踪。在读取循环之前检查,所以一个 Date.now() 覆盖
// 此 readable 事件中的所有 chunk。
const now = Date.now();
if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) {
this.props.onStdinResume?.();
}
this.lastStdinTime = now;
try {
let chunk;
while ((chunk = this.props.stdin.read() as string | null) !== null) {
// 处理输入 chunk
this.processInput(chunk);
}
} catch (error) {
// 在 Bun 中,流 'readable' 处理程序内部的未捕获抛出可能会
// 永久楔住流:数据保持缓冲,'readable'
// 永不重新发出。在此捕获可确保流保持健康,
// 以便后续按键仍能传递。
logError(error);
// 重新附加监听器以防异常分离了它。
// Bun 可能会在错误后移除监听器;没有这个,
// 会话会永久冻结stdin 读取器死亡,事件循环存活)。
const {
stdin
} = this.props;
if (this.rawModeEnabledCount > 0 && !stdin.listeners('readable').includes(this.handleReadable)) {
logForDebugging('handleReadable: re-attaching stdin readable listener after error recovery', {
level: 'warn'
});
stdin.addListener('readable', this.handleReadable);
}
}
};
handleInput = (input: string | undefined): void => {
// 按 Ctrl+C 退出
if (input === '\x03' && this.props.exitOnCtrlC) {
this.handleExit();
}
// 注意Ctrl+Z挂起现在在 processKeysInBatch 中使用解析的键处理,
// 以支持来自 Kitty 键盘协议终端Ghostty、iTerm2、kitty、WezTerm
// 的原始格式(\x1a和 CSI u 格式
};
handleExit = (error?: Error): void => {
if (this.isRawModeSupported()) {
this.handleSetRawMode(false);
}
this.props.onExit(error);
};
handleTerminalFocus = (isFocused: boolean): void => {
// setTerminalFocused 通知订阅者TerminalFocusProvider上下文
// 和 Clock间隔速度——不需要 App setState。
setTerminalFocused(isFocused);
};
handleSuspend = (): void => {
if (!this.isRawModeSupported()) {
return;
}
// 存储确切的原始模式计数以正确恢复
const rawModeCountBeforeSuspend = this.rawModeEnabledCount;
// 在挂起前完全禁用原始模式
while (this.rawModeEnabledCount > 0) {
this.handleSetRawMode(false);
}
// 在挂起前显示光标、禁用焦点报告和禁用鼠标跟踪。
// DISABLE_MOUSE_TRACKING 如果跟踪未启用则是无操作,
// 所以可以安全地无条件发出——没有它,
// SGR 鼠标序列会在挂起期间的 shell 提示符下显示为乱码文本。
if (this.props.stdout.isTTY) {
this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING);
}
// 为 Claude Code 处理发出挂起事件。主要是通知
this.internal_eventEmitter.emit('suspend');
// 设置恢复处理程序
const resumeHandler = () => {
// 恢复原始模式到完全之前的状态
for (let i = 0; i < rawModeCountBeforeSuspend; i++) {
if (this.isRawModeSupported()) {
this.handleSetRawMode(true);
}
}
// 恢复后隐藏光标(除非在无障碍模式)并重新启用焦点报告
if (this.props.stdout.isTTY) {
if (!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) {
this.props.stdout.write(HIDE_CURSOR);
}
// 重新启用焦点报告以恢复终端状态
this.props.stdout.write(EFE);
}
// 为 Claude Code 处理发出恢复事件
this.internal_eventEmitter.emit('resume');
process.removeListener('SIGCONT', resumeHandler);
};
process.on('SIGCONT', resumeHandler);
process.kill(process.pid, 'SIGSTOP');
};
}
/**
* 在单个离散更新上下文中处理所有键的帮助器。
* discreteUpdates 期望 (fn, a, b, c, d) -> fn(a, b, c, d)
*/
function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined, _unused2: undefined): void {
/**
* 更新通知超时跟踪的交互时间。
* 这从中央输入处理程序调用,以避免有多个
* 可能导致竞争条件和丢失输入的 stdin 监听器。
* 终端响应kind: 'response')是自动化的,不是用户输入。
* mode-1003 无按钮运动也被排除——被动光标漂移不是交互
*(会抑制空闲通知 + 延迟维护)。
*/
if (items.some(i => i.kind === 'key' || i.kind === 'mouse' && !((i.button & 0x20) !== 0 && (i.button & 0x03) === 3))) {
updateLastInteractionTime();
}
for (const item of items) {
/**
* 终端响应DECRPM、DA1、OSC 回复等)不是用户
* 输入——将它们路由到 querier 以解析待处理的 promise。
*/
if (item.kind === 'response') {
app.querier.onResponse(item.response);
continue;
}
/**
* 鼠标点击/拖动事件更新选择状态(仅全屏)。
* 终端发送 1-indexed col/row转换为
* 屏幕缓冲区的 0-indexed。按钮位 0x20 = 拖动(按住按钮时的运动)。
*/
if (item.kind === 'mouse') {
handleMouseEvent(app, item);
continue;
}
const sequence = item.sequence;
/**
* 处理终端焦点事件 (DECSET 1004)
*/
if (sequence === FOCUS_IN) {
app.handleTerminalFocus(true);
const event = new TerminalFocusEvent('terminalfocus');
app.internal_eventEmitter.emit('terminalfocus', event);
continue;
}
if (sequence === FOCUS_OUT) {
app.handleTerminalFocus(false);
/**
* 防御性:如果我们丢失了释放事件(鼠标释放在
* 终端窗口外——一些模拟器会丢弃它而不是捕获
* 指针focus-out 是拖动结束的下一个可观察信号。
* 没有这个,拖动滚动的定时器会一直运行直到滚动
* 边界被击中。
*/
if (app.props.selection.isDragging) {
finishSelection(app.props.selection);
app.props.onSelectionChange();
}
const event = new TerminalFocusEvent('terminalblur');
app.internal_eventEmitter.emit('terminalblur', event);
continue;
}
/**
* 失败安全:如果我们收到输入,终端必定是焦点的
*/
if (!getTerminalFocused()) {
setTerminalFocused(true);
}
/**
* 使用解析的键处理 Ctrl+Z挂起以支持原始格式\x1a
* 来自 Kitty 键盘协议终端的 CSI u 格式(\x1b[122;5u
*/
if (item.name === 'z' && item.ctrl && SUPPORTS_SUSPEND) {
app.handleSuspend();
continue;
}
app.handleInput(sequence);
const event = new InputEvent(item);
app.internal_eventEmitter.emit('input', event);
/**
* 也通过 DOM 树分发,以便 onKeyDown 处理程序触发。
*/
app.props.dispatchKeyboardEvent(item);
}
}
/** 导出用于测试。修改 app.props.selection 和点击/悬停状态。 */
export function handleMouseEvent(app: App, m: ParsedMouse): void {
/**
* 允许在保持滚轮滚动的同时禁用点击处理(滚轮通过
* 键绑定系统作为 'wheelup'/'wheeldown',而不是这里)。
*/
if (isMouseClicksDisabled()) return;
const sel = app.props.selection;
/**
* 终端坐标是 1-indexed屏幕缓冲区是 0-indexed
*/
const col = m.col - 1;
const row = m.row - 1;
const baseButton = m.button & 0x03;
if (m.action === 'press') {
if ((m.button & 0x20) !== 0 && baseButton === 3) {
/**
* Mode-1003 无按钮按住时的运动。分发悬停;跳过
* 此处理程序的其余部分(无选择,无点击计数副作用)。
* 丢失释放恢复:无按钮运动而 isDragging=true 意味着
* 释放在终端窗口外发生iTerm2 不捕获
* 窗口边界外的指针,所以 SGR 'm' 永远不会
* 到达)。在这里完成选择,以便复制时选择触发。
* FOCUS_OUT 处理程序涵盖"切换应用"情况,但不涵盖"释放
* 后越过边缘,回来"——而 tmux 除非设置 `focus-events on`
* 否则会丢弃焦点事件,所以这是更可靠的信号。
*/
if (sel.isDragging) {
finishSelection(sel);
app.props.onSelectionChange();
}
if (col === app.lastHoverCol && row === app.lastHoverRow) return;
app.lastHoverCol = col;
app.lastHoverRow = row;
app.props.onHoverAt(col, row);
return;
}
if (baseButton !== 0) {
/**
* 非左键按下打断多击链。
*/
app.clickCount = 0;
return;
}
if ((m.button & 0x20) !== 0) {
/**
* 拖动运动模式感知扩展char/word/line
* onSelectionDrag 内部调用 notifySelectionChange——无需额外的 onSelectionChange。
*/
app.props.onSelectionDrag(col, row);
return;
}
/**
* 仅限 mode-1002 终端的丢失释放回退:当 isDragging=true 时
* 新按压意味着之前的释放被丢弃(光标
* 离开窗口)。完成该选择,以便在 startSelection/onMultiClick
* 覆盖它之前触发复制选择。mode-1003 终端
* 改为命中上面的无按钮运动恢复,所以这很少见。
*/
if (sel.isDragging) {
finishSelection(sel);
app.props.onSelectionChange();
}
/**
* 新的左键按下。在此处检测多击(而不是在释放时),
* 以便单词/行高亮立即出现,并且后续拖动可以
* 像原生 macOS 一样按单词/行扩展。之前在
* 释放时检测,意味着 (a) 单词高亮前有可见延迟,
* (b) 双击+拖动退化为 char 模式选择。
*/
const now = Date.now();
const nearLast = now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS && Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE && Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE;
app.clickCount = nearLast ? app.clickCount + 1 : 1;
app.lastClickTime = now;
app.lastClickCol = col;
app.lastClickRow = row;
if (app.clickCount >= 2) {
/**
* 取消第一次点击的任何待处理超链接打开——这是
* 双击,而不是链接上的单点击。
*/
if (app.pendingHyperlinkTimer) {
clearTimeout(app.pendingHyperlinkTimer);
app.pendingHyperlinkTimer = null;
}
/**
* 四次及以上点击限制为 3行选择
*/
const count = app.clickCount === 2 ? 2 : 3;
app.props.onMultiClick(col, row, count);
return;
}
startSelection(sel, col, row);
/**
* SGR 位 0x08 = altxterm.js 在此处连接 altKey而不是 metaKey——
* 见下面超链接打开守卫处的注释)。在 macOS xterm.js 上,
* 收到 alt 意味着 macOptionClickForcesSelection 是 OFF 的
*(否则 xterm.js 会为原生选择消费事件)。
*/
sel.lastPressHadAlt = (m.button & 0x08) !== 0;
app.props.onSelectionChange();
return;
}
/**
* 释放:即使是非零按钮代码也结束拖动。一些终端
* 用运动位或按钮=3"无按钮"编码释放(从
* pre-SGR X10 编码继承)——过滤这些会使
* isDragging=true 孤立,并使拖动滚动的定时器保持运行直到
* 滚动边界。只有当我们正在拖动时才对非左键释放采取行动
*(所以无关的中键/右键点击释放不会触及选择)。
*/
if (baseButton !== 0) {
if (!sel.isDragging) return;
finishSelection(sel);
app.props.onSelectionChange();
return;
}
finishSelection(sel);
/**
* 注意:与旧的基于释放的检测不同,我们在拖动释放后不重置 clickCount。
* 这与 NSEvent.clickCount 语义一致:
* 介入的拖动不会打断点击链。实际好处:
* 预期双击期间的触控板抖动press→wobble→release→press
* 现在正确解析为 word-select而不是退化为新的
* 单击。nearLast 窗口500ms1 个单元格)限制效果——
* 超过该限制的故意拖动只是开始一个新的链。
* 在 char 模式下,带拖动和无拖动的 press+release 是点击:设置锚点,
* focus null → hasSelection false。在 word/line 模式下press 已经
* 设置了锚点+焦点hasSelection true所以释放只是保持
* 高亮。锚点检查防止孤立的释放(无
* 先前 press——例如启用鼠标跟踪时按住了按钮
*/
if (!hasSelection(sel) && sel.anchor) {
/**
* 单击:立即分发 DOM 点击(光标重新定位
* 等对延迟敏感)。如果没有 DOM 处理程序消耗它,
* 延迟超链接检查,以便第二次点击可以取消它。
*/
if (!app.props.onClickAt(col, row)) {
/**
* 当屏幕缓冲区仍反映用户点击的内容时同步解析超链接 URL——
* 仅延迟浏览器打开,以便双击可以取消它。
*/
const url = app.props.getHyperlinkAt(col, row);
/**
* xterm.jsVS Code、Cursor、Windsurf 等)有自己的 OSC 8 链接
* 处理程序,在 Cmd+点击时触发,而不消耗鼠标事件
*Linkifier._handleMouseUp 调用 link.activate() 但从不
* preventDefault/stopPropagation。点击也作为 SGR 转发到
* pty所以 VS Code 的 terminalLinkManager 和我们的处理程序
* 都会打开 URL——两次。我们不能按 Cmd 过滤xterm.js
* 在 SGR 编码之前丢弃 metaKeyICoreMouseEvent 没有 meta
* 字段;我们称为 'meta' 的 SGR 位连接到 alt
* 让 xterm.js 拥有链接打开Cmd+click 在那里是原生 UX。
* TERM_PROGRAM 是同步快速路径isXtermJs() 是 XTVERSION
* 探测结果(捕获 SSH + 非 VS Code 嵌入器如 Hyper
*/
if (url && process.env.TERM_PROGRAM !== 'vscode' && !isXtermJs()) {
/**
* 清除任何先前待处理的定时器——点击第二个链接
* 取代第一个(只有最新点击打开)。
*/
if (app.pendingHyperlinkTimer) {
clearTimeout(app.pendingHyperlinkTimer);
}
app.pendingHyperlinkTimer = setTimeout((app, url) => {
app.pendingHyperlinkTimer = null;
app.props.onOpenHyperlink(url);
}, MULTI_CLICK_TIMEOUT_MS, app, url);
}
}
}
app.props.onSelectionChange();
}

View File

@@ -0,0 +1,21 @@
import { createContext } from 'react'
export type Props = {
/**
* 退出(卸载)整个 Ink 应用。
*/
readonly exit: (error?: Error) => void
}
/**
* `AppContext` 是一个 React context暴露手动退出应用卸载的方法。
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
const AppContext = createContext<Props>({
exit() {},
})
// eslint-disable-next-line custom-rules/no-top-level-side-effects
AppContext.displayName = 'InternalAppContext'
export default AppContext

View File

@@ -0,0 +1,119 @@
import '../global.d.ts';
import React, { type PropsWithChildren, type Ref } from 'react';
import type { Except } from 'type-fest';
import type { DOMElement } from '../dom.js';
import type { ClickEvent } from '../events/click-event.js';
import type { FocusEvent } from '../events/focus-event.js';
import type { KeyboardEvent } from '../events/keyboard-event.js';
import type { Styles } from '../styles.js';
import * as warn from '../warn.js';
export type Props = Except<Styles, 'textWrap'> & {
ref?: Ref<DOMElement>;
/**
* Tab 顺序索引。具有 `tabIndex >= 0` 的节点参与
* Tab/Shift+Tab 循环;`-1` 意味着仅可通过程序聚焦。
*/
tabIndex?: number;
/**
* 在挂载时聚焦此元素。像 HTML `autofocus`
* 属性 — FocusManager 在 reconciler 的 `commitMount` 阶段
* 调用 `focus(node)`。
*/
autoFocus?: boolean;
/**
* 在左键点击时触发(按下 + 释放无拖动)。仅在
* 启用了鼠标跟踪的 `<AlternateScreen>` 内工作 — 否则无操作。
* 事件从最深命中 Box 向上冒泡到
* 祖先;调用 `event.stopImmediatePropagation()` 停止冒泡。
*/
onClick?: (event: ClickEvent) => void;
onFocus?: (event: FocusEvent) => void;
onFocusCapture?: (event: FocusEvent) => void;
onBlur?: (event: FocusEvent) => void;
onBlurCapture?: (event: FocusEvent) => void;
onKeyDown?: (event: KeyboardEvent) => void;
onKeyDownCapture?: (event: KeyboardEvent) => void;
/**
* 当鼠标进入此 Box 的渲染矩形时触发。类似于 DOM
* `mouseenter`,不冒泡 — 在子节点之间移动不会
* 在父节点上重新触发。仅在启用了
* mode-1003 鼠标跟踪的 `<AlternateScreen>` 内工作。
*/
onMouseEnter?: () => void;
/** 当鼠标移出此 Box 的渲染矩形时触发。 */
onMouseLeave?: () => void;
};
/**
* `<Box>` 是一个构建布局的基本 Ink 组件。它类似于浏览器中的 `<div style="display: flex">`。
*/
function Box({
children,
flexWrap = "nowrap",
flexDirection = "row",
flexGrow = 0,
flexShrink = 1,
ref,
tabIndex,
autoFocus,
onClick,
onFocus,
onFocusCapture,
onBlur,
onBlurCapture,
onMouseEnter,
onMouseLeave,
onKeyDown,
onKeyDownCapture,
...style
}: PropsWithChildren<Props>): React.ReactNode {
// 警告如果间距值不是整数以防止分数布局尺寸
warn.ifNotInteger(style.margin, "margin");
warn.ifNotInteger(style.marginX, "marginX");
warn.ifNotInteger(style.marginY, "marginY");
warn.ifNotInteger(style.marginTop, "marginTop");
warn.ifNotInteger(style.marginBottom, "marginBottom");
warn.ifNotInteger(style.marginLeft, "marginLeft");
warn.ifNotInteger(style.marginRight, "marginRight");
warn.ifNotInteger(style.padding, "padding");
warn.ifNotInteger(style.paddingX, "paddingX");
warn.ifNotInteger(style.paddingY, "paddingY");
warn.ifNotInteger(style.paddingTop, "paddingTop");
warn.ifNotInteger(style.paddingBottom, "paddingBottom");
warn.ifNotInteger(style.paddingLeft, "paddingLeft");
warn.ifNotInteger(style.paddingRight, "paddingRight");
warn.ifNotInteger(style.gap, "gap");
warn.ifNotInteger(style.columnGap, "columnGap");
warn.ifNotInteger(style.rowGap, "rowGap");
return (
<ink-box
ref={ref}
tabIndex={tabIndex}
autoFocus={autoFocus}
onClick={onClick}
onFocus={onFocus}
onFocusCapture={onFocusCapture}
onBlur={onBlur}
onBlurCapture={onBlurCapture}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onKeyDown={onKeyDown}
onKeyDownCapture={onKeyDownCapture}
style={{
flexWrap,
flexDirection,
flexGrow,
flexShrink,
...style,
overflowX: style.overflowX ?? style.overflow ?? 'visible',
overflowY: style.overflowY ?? style.overflow ?? 'visible',
}}
>
{children}
</ink-box>
)
}
export default Box

View File

@@ -0,0 +1,227 @@
import { c as _c } from "react/compiler-runtime";
import React, { type Ref, useCallback, useEffect, useRef, useState } from 'react';
import type { Except } from 'type-fest';
import type { DOMElement } from '../dom.js';
import type { ClickEvent } from '../events/click-event.js';
import type { FocusEvent } from '../events/focus-event.js';
import type { KeyboardEvent } from '../events/keyboard-event.js';
import type { Styles } from '../styles.js';
import Box from './Box.js';
/**
* 按钮的交互状态。
*/
type ButtonState = {
/** 是否获得焦点 */
focused: boolean;
/** 是否被悬停 */
hovered: boolean;
/** 是否处于激活状态(按下) */
active: boolean;
};
export type Props = Except<Styles, 'textWrap'> & {
ref?: Ref<DOMElement>;
/**
* 当按钮通过 Enter、空格或点击激活时调用。
*/
onAction: () => void;
/**
* Tab 顺序索引。默认为 0按 Tab 顺序)。
* 设置为 -1 仅为程序可聚焦。
*/
tabIndex?: number;
/**
* 挂载时聚焦此按钮。
*/
autoFocus?: boolean;
/**
* 接收交互状态的渲染属性。使用此属性基于
* focus/hover/active 为子节点应用样式——Button 本身
* 有意不设置样式。
*
* 如果未提供,子节点按原样渲染(无状态依赖样式)。
*/
children: ((state: ButtonState) => React.ReactNode) | React.ReactNode;
};
/**
* Button 组件。
* 处理焦点、悬停、激活状态,并通过 onAction 回调报告交互。
*/
function Button(t0) {
const $ = _c(30);
let autoFocus;
let children;
let onAction;
let ref;
let style;
let t1;
if ($[0] !== t0) {
({
onAction,
tabIndex: t1,
autoFocus,
children,
ref,
...style
} = t0);
$[0] = t0;
$[1] = autoFocus;
$[2] = children;
$[3] = onAction;
$[4] = ref;
$[5] = style;
$[6] = t1;
} else {
autoFocus = $[1];
children = $[2];
onAction = $[3];
ref = $[4];
style = $[5];
t1 = $[6];
}
const tabIndex = t1 === undefined ? 0 : t1;
const [isFocused, setIsFocused] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isActive, setIsActive] = useState(false);
const activeTimer = useRef(null);
let t2;
let t3;
if ($[7] === Symbol.for("react.memo_cache_sentinel")) {
t2 = () => () => {
if (activeTimer.current) {
clearTimeout(activeTimer.current);
}
};
t3 = [];
$[7] = t2;
$[8] = t3;
} else {
t2 = $[7];
t3 = $[8];
}
useEffect(t2, t3);
let t4;
if ($[9] !== onAction) {
t4 = e => {
if (e.key === "return" || e.key === " ") {
e.preventDefault();
setIsActive(true);
onAction();
if (activeTimer.current) {
clearTimeout(activeTimer.current);
}
activeTimer.current = setTimeout(_temp, 100, setIsActive);
}
};
$[9] = onAction;
$[10] = t4;
} else {
t4 = $[10];
}
const handleKeyDown = t4;
let t5;
if ($[11] !== onAction) {
t5 = _e => {
onAction();
};
$[11] = onAction;
$[12] = t5;
} else {
t5 = $[12];
}
const handleClick = t5;
let t6;
if ($[13] === Symbol.for("react.memo_cache_sentinel")) {
t6 = _e_0 => setIsFocused(true);
$[13] = t6;
} else {
t6 = $[13];
}
const handleFocus = t6;
let t7;
if ($[14] === Symbol.for("react.memo_cache_sentinel")) {
t7 = _e_1 => setIsFocused(false);
$[14] = t7;
} else {
t7 = $[14];
}
const handleBlur = t7;
let t8;
if ($[15] === Symbol.for("react.memo_cache_sentinel")) {
t8 = () => setIsHovered(true);
$[15] = t8;
} else {
t8 = $[15];
}
const handleMouseEnter = t8;
let t9;
if ($[16] === Symbol.for("react.memo_cache_sentinel")) {
t9 = () => setIsHovered(false);
$[16] = t9;
} else {
t9 = $[16];
}
const handleMouseLeave = t9;
let t10;
if ($[17] !== children || $[18] !== isActive || $[19] !== isFocused || $[20] !== isHovered) {
const state = {
focused: isFocused,
hovered: isHovered,
active: isActive
};
t10 = typeof children === "function" ? children(state) : children;
$[17] = children;
$[18] = isActive;
$[19] = isFocused;
$[20] = isHovered;
$[21] = t10;
} else {
t10 = $[21];
}
const content = t10;
let t11;
if ($[22] !== autoFocus || $[23] !== content || $[24] !== handleClick || $[25] !== handleKeyDown || $[26] !== ref || $[27] !== style || $[28] !== tabIndex) {
t11 = <Box ref={ref} tabIndex={tabIndex} autoFocus={autoFocus} onKeyDown={handleKeyDown} onClick={handleClick} onFocus={handleFocus} onBlur={handleBlur} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...style}>{content}</Box>;
$[22] = autoFocus;
$[23] = content;
$[24] = handleClick;
$[25] = handleKeyDown;
$[26] = ref;
$[27] = style;
$[28] = tabIndex;
$[29] = t11;
} else {
t11 = $[29];
}
return t11;
}
function _temp(setter) {
return setter(false);
}
export default Button;
export type { ButtonState };

View File

@@ -0,0 +1,149 @@
import { c as _c } from "react/compiler-runtime";
import React, { createContext, useEffect, useState } from 'react';
import { FRAME_INTERVAL_MS } from '../constants.js';
import { useTerminalFocus } from '../hooks/use-terminal-focus.js';
/**
* 时钟类型 - 提供订阅、时间和 tick 间隔设置功能。
*/
export type Clock = {
/**
* 订阅变更通知。
* @param onChange - 变更回调函数
* @param keepAlive - 是否保持时钟运行
* @returns 取消订阅函数
*/
subscribe: (onChange: () => void, keepAlive: boolean) => () => void;
/**
* 获取当前时间。
*/
now: () => number;
/**
* 设置 tick 间隔。
*/
setTickInterval: (ms: number) => void;
};
/**
* 创建时钟实例。
* 时钟管理一组订阅者,并在指定间隔触发更新。
*/
export function createClock(tickIntervalMs: number): Clock {
const subscribers = new Map<() => void, boolean>();
let interval: ReturnType<typeof setInterval> | null = null;
let currentTickIntervalMs = tickIntervalMs;
let startTime = 0;
// 当前 tick 时间的快照,确保同一 tick 中的所有订阅者
// 看到相同的值(保持动画同步)
let tickTime = 0;
function tick(): void {
tickTime = Date.now() - startTime;
for (const onChange of subscribers.keys()) {
onChange();
}
}
function updateInterval(): void {
const anyKeepAlive = [...subscribers.values()].some(Boolean);
if (anyKeepAlive) {
if (interval) {
clearInterval(interval);
interval = null;
}
if (startTime === 0) {
startTime = Date.now();
}
interval = setInterval(tick, currentTickIntervalMs);
} else if (interval) {
clearInterval(interval);
interval = null;
}
}
return {
subscribe(onChange, keepAlive) {
subscribers.set(onChange, keepAlive);
updateInterval();
return () => {
subscribers.delete(onChange);
updateInterval();
};
},
now() {
if (startTime === 0) {
startTime = Date.now();
}
// 当时钟间隔运行时,返回同步的 tickTime
// 以便同一 tick 中的所有订阅者看到相同的值。
// 当暂停时(没有 keepAlive 订阅者),返回实时时间,
// 以避免从暂停前最后一个 tick 返回过时的 tickTime。
if (interval && tickTime) {
return tickTime;
}
return Date.now() - startTime;
},
setTickInterval(ms) {
if (ms === currentTickIntervalMs) return;
currentTickIntervalMs = ms;
updateInterval();
}
};
}
/**
* 时钟上下文 - 提供全局时钟实例给子组件。
*/
export const ClockContext = createContext<Clock | null>(null);
/**
* 失焦时的 tick 间隔(正常帧间隔的两倍)。
*/
const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2;
/**
* 独立的组件,这样 App.tsx 不会在创建时钟时重新渲染。
* 时钟值是稳定的(通过 useState 创建一次),
* 所以提供者本身永远不会导致消费者重新渲染。
*/
export function ClockProvider(t0) {
const $ = _c(7);
const {
children
} = t0;
const [clock] = useState(_temp);
const focused = useTerminalFocus();
let t1;
let t2;
if ($[0] !== clock || $[1] !== focused) {
t1 = () => {
clock.setTickInterval(focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS);
};
t2 = [clock, focused];
$[0] = clock;
$[1] = focused;
$[2] = t1;
$[3] = t2;
} else {
t1 = $[2];
t2 = $[3];
}
useEffect(t1, t2);
let t3;
if ($[4] !== children || $[5] !== clock) {
t3 = <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>;
$[4] = children;
$[5] = clock;
$[6] = t3;
} else {
t3 = $[6];
}
return t3;
}
function _temp() {
return createClock(FRAME_INTERVAL_MS);
}

View File

@@ -0,0 +1,31 @@
import { createContext } from 'react'
import type { DOMElement } from '../dom.js'
export type CursorDeclaration = {
/** 声明节点内的显示列(终端单元格宽度) */
readonly relativeX: number
/** 声明节点内的行号 */
readonly relativeY: number
/** 其 yoga 布局提供绝对原点的 ink-box DOMElement */
readonly node: DOMElement
}
/**
* 声明的光标位置的设置器。
*
* 可选的第二个参数使 `null` 成为条件清除:声明仅在
* 当前声明的节点匹配 `clearIfNode` 时才被清除。
* 这使得 hook 对于兄弟组件(例如列表项)之间的焦点转移是安全的
* ——如果没有节点检查,新失焦项的清除可能会覆盖
* 新聚焦的兄弟项的设置,取决于布局效果的顺序。
*/
export type CursorDeclarationSetter = (
declaration: CursorDeclaration | null,
clearIfNode?: DOMElement | null,
) => void
const CursorDeclarationContext = createContext<CursorDeclarationSetter>(
() => {},
)
export default CursorDeclarationContext

View File

@@ -0,0 +1,117 @@
import codeExcerpt, { type CodeExcerpt } from 'code-excerpt';
import { readFileSync } from 'fs';
import React from 'react';
import StackUtils from 'stack-utils';
import Box from './Box.js';
import Text from './Text.js';
/* eslint-disable custom-rules/no-process-cwd -- stack trace file:// paths are relative to the real OS cwd, not the virtual cwd */
/**
* 错误源文件报告为 file:///home/user/file.js
* 此函数移除 file://[cwd] 部分
*/
const cleanupPath = (path: string | undefined): string | undefined => {
return path?.replace(`file://${process.cwd()}/`, '');
};
let stackUtils: StackUtils | undefined;
function getStackUtils(): StackUtils {
return stackUtils ??= new StackUtils({
cwd: process.cwd(),
internals: StackUtils.nodeInternals()
});
}
/* eslint-enable custom-rules/no-process-cwd */
type Props = {
readonly error: Error;
};
/**
* ErrorOverview 组件 - 显示错误信息、堆栈跟踪和源代码上下文。
*/
export default function ErrorOverview({
error
}: Props) {
const stack = error.stack ? error.stack.split('\n').slice(1) : undefined;
const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined;
const filePath = cleanupPath(origin?.file);
let excerpt: CodeExcerpt[] | undefined;
let lineWidth = 0;
if (filePath && origin?.line) {
try {
// eslint-disable-next-line custom-rules/no-sync-fs -- sync render path; error overlay can't go async without suspense restructuring
const sourceCode = readFileSync(filePath, 'utf8');
excerpt = codeExcerpt(sourceCode, origin.line);
if (excerpt) {
for (const {
line
} of excerpt) {
lineWidth = Math.max(lineWidth, String(line).length);
}
}
} catch {
// 文件不可读——跳过源代码上下文
}
}
return <Box flexDirection="column" padding={1}>
<Box>
<Text backgroundColor="ansi:red" color="ansi:white">
{' '}
ERROR{' '}
</Text>
<Text> {error.message}</Text>
</Box>
{origin && filePath && <Box marginTop={1}>
<Text dim>
{filePath}:{origin.line}:{origin.column}
</Text>
</Box>}
{origin && excerpt && <Box marginTop={1} flexDirection="column">
{excerpt.map(({
line: line_0,
value
}) => <Box key={line_0}>
<Box width={lineWidth + 1}>
<Text dim={line_0 !== origin.line} backgroundColor={line_0 === origin.line ? 'ansi:red' : undefined} color={line_0 === origin.line ? 'ansi:white' : undefined}>
{String(line_0).padStart(lineWidth, ' ')}:
</Text>
</Box>
<Text key={line_0} backgroundColor={line_0 === origin.line ? 'ansi:red' : undefined} color={line_0 === origin.line ? 'ansi:white' : undefined}>
{' ' + value}
</Text>
</Box>)}
</Box>}
{error.stack && <Box marginTop={1} flexDirection="column">
{error.stack.split('\n').slice(1).map(line_1 => {
const parsedLine = getStackUtils().parseLine(line_1);
// 如果堆栈中的行无法解析,则打印未解析的行。
if (!parsedLine) {
return <Box key={line_1}>
<Text dim>- </Text>
<Text bold>{line_1}</Text>
</Box>;
}
return <Box key={line_1}>
<Text dim>- </Text>
<Text bold>{parsedLine.function}</Text>
<Text dim>
{' '}
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:
{parsedLine.column})
</Text>
</Box>;
})}
</Box>}
</Box>;
}

View File

@@ -0,0 +1,36 @@
import type { ReactNode } from 'react';
import React from 'react';
import { supportsHyperlinks } from '../supports-hyperlinks.js';
import Text from './Text.js';
export type Props = {
readonly children?: ReactNode;
readonly url: string;
readonly fallback?: ReactNode;
};
/**
* Link 组件 - 渲染超链接
*
* 如果终端支持超链接,则使用 <ink-link> 渲染可点击链接;
* 否则回退到显示 URL 或子内容。
*/
export default function Link({
children,
url,
fallback,
}: Props): React.ReactNode {
// 如果提供了 children 则使用,否则显示 URL
const content = children ?? url;
if (supportsHyperlinks()) {
// 包装在 Text 中以确保处于文本上下文
//ink-link 是类似 ink-text 的文本元素)
return (
<Text>
<ink-link href={url}>{content}</ink-link>
</Text>
);
}
return <Text>{fallback ?? content}</Text>;
}

View File

@@ -0,0 +1,38 @@
import { c as _c } from "react/compiler-runtime";
import React from 'react';
export type Props = {
/**
* 插入的换行符数量。
*
* @default 1
*/
readonly count?: number;
};
/**
* 添加一个或多个换行符(\n字符。必须在 <Text> 组件内使用。
*/
export default function Newline(t0) {
const $ = _c(4);
const {
count: t1
} = t0;
const count = t1 === undefined ? 1 : t1;
let t2;
if ($[0] !== count) {
t2 = "\n".repeat(count);
$[0] = count;
$[1] = t2;
} else {
t2 = $[1];
}
let t3;
if ($[2] !== t2) {
t3 = <ink-text>{t2}</ink-text>;
$[2] = t2;
$[3] = t3;
} else {
t3 = $[3];
}
return t3;
}

View File

@@ -0,0 +1,67 @@
import { c as _c } from "react/compiler-runtime";
import React, { type PropsWithChildren } from 'react';
import Box, { type Props as BoxProps } from './Box.js';
type Props = Omit<BoxProps, 'noSelect'> & {
/**
* 将排除区域从第 0 列扩展到此盒子的右边缘,
* 适用于此盒子占据的每一行。用于在更宽的缩进容器内
* 呈现的边沟(例如工具消息行内的差异):
* 没有这个,多行拖动会在前缀以下的行上
* 拾取容器的领先缩进。
*
* @default false
*/
fromLeftEdge?: boolean;
};
/**
* 在全屏文本选择中将其内容标记为不可选择。
* 此框内的单元格会被选择高亮和复制文本跳过——
* 用户拖动时边沟保持视觉上不变,
* 这样可以清楚地知道什么将被复制。
*
* 用于隔离边沟(行号、差异 +/- 符号、列表项目符号),
* 以便点击拖动渲染代码产生干净的可粘贴内容:
*
* <Box flexDirection="row">
* <NoSelect fromLeftEdge><Text dimColor> 42 +</Text></NoSelect>
* <Text>const x = 1</Text>
* </Box>
*
* 仅影响备用屏幕文本选择(带有鼠标跟踪的 <AlternateScreen>)。
* 在主屏幕滚动回渲染中无效——那里使用终端的本机选择。
*/
export function NoSelect(t0) {
const $ = _c(8);
let boxProps;
let children;
let fromLeftEdge;
if ($[0] !== t0) {
({
children,
fromLeftEdge,
...boxProps
} = t0);
$[0] = t0;
$[1] = boxProps;
$[2] = children;
$[3] = fromLeftEdge;
} else {
boxProps = $[1];
children = $[2];
fromLeftEdge = $[3];
}
const t1 = fromLeftEdge ? "from-left-edge" : true;
let t2;
if ($[4] !== boxProps || $[5] !== children || $[6] !== t1) {
t2 = <Box {...boxProps} noSelect={t1}>{children}</Box>;
$[4] = boxProps;
$[5] = children;
$[6] = t1;
$[7] = t2;
} else {
t2 = $[7];
}
return t2;
}

View File

@@ -0,0 +1,57 @@
import { c as _c } from "react/compiler-runtime";
import React from 'react';
type Props = {
/**
* 预渲染的 ANSI 行。每个元素必须是恰好一个终端行
*(由生产者已包装到 `width`)并带有内联 ANSI 转义码。
*/
lines: string[];
/** 生产者包装到的列宽。发送给 Yoga 作为固定叶子宽度。 */
width: number;
};
/**
* 绕过 <Ansi> → React 树 → Yoga → squash → 重新序列化的往返,
* 用于已经是终端就绪的内容。
*
* 当外部渲染器(例如 ColorDiff NAPI 模块)已经产生
* ANSI 转义、宽度包装的输出时使用此组件。
* 普通的 <Ansi> 挂载将输出重新解析为每个样式跨度的一个 React <Text>
* 将每个跨度布局为 Yoga flex 子节点,然后遍历树重新发出
* 相同的转义码。对于充满语法高亮差异的长转录,
* 那是渲染的主要成本。
*
* 此组件发出一个具有常量时间测量函数
*width × lines.length的单个 Yoga 叶子,
* 并将连接的字符串直接交给 output.write()
* 后者已经通过 '\n' 分割并解析 ANSI 到屏幕缓冲区。
*/
export function RawAnsi(t0) {
const $ = _c(6);
const {
lines,
width
} = t0;
if (lines.length === 0) {
return null;
}
let t1;
if ($[0] !== lines) {
t1 = lines.join("\n");
$[0] = lines;
$[1] = t1;
} else {
t1 = $[1];
}
let t2;
if ($[2] !== lines.length || $[3] !== t1 || $[4] !== width) {
t2 = <ink-raw-ansi rawText={t1} rawWidth={width} rawHeight={lines.length} />;
$[2] = lines.length;
$[3] = t1;
$[4] = width;
$[5] = t2;
} else {
t2 = $[5];
}
return t2;
}

View File

@@ -0,0 +1,305 @@
import React, { type PropsWithChildren, type Ref, useImperativeHandle, useRef, useState } from 'react';
import type { Except } from 'type-fest';
import { markScrollActivity } from '../../bootstrap/state.js';
import type { DOMElement } from '../dom.js';
import { markDirty, scheduleRenderFrom } from '../dom.js';
import { markCommitStart } from '../reconciler.js';
import type { Styles } from '../styles.js';
import '../global.d.ts';
import Box from './Box.js';
/**
* ScrollBox 的命令式滚动 API。
*/
export type ScrollBoxHandle = {
/**
* 将滚动位置设置为指定 Y 坐标。
*/
scrollTo: (y: number) => void;
/**
* 按指定增量滚动。
*/
scrollBy: (dy: number) => void;
/**
* 滚动使 `el` 的顶部位于视口顶部(加 `offset`)。
* 与 scrollTo 不同scrollTo 会缓存一个数值,
* 等到节流渲染触发时该数值已经过时;
* 此方法将位置读取延迟到渲染时——
* render-node-to-output 在同一个 Yoga pass 中读取
* `el.yogaNode.getComputedTop()`,同时计算 scrollHeight。
* 确定性。一次性。
*/
scrollToElement: (el: DOMElement, offset?: number) => void;
/**
* 滚动到底部。
*/
scrollToBottom: () => void;
/**
* 获取当前滚动位置。
*/
getScrollTop: () => number;
/**
* 获取已累积但尚未排出的增量。
* useVirtualScroll 需要此值来挂载
* [已提交, 已提交+待处理] 的并集范围——
* 否则中间排出帧会找不到子节点(空白)。
*/
getPendingDelta: () => number;
/**
* 获取滚动内容高度。
*/
getScrollHeight: () => number;
/**
* 类似 getScrollHeight但直接读取 Yoga 而不是
* render-node-to-output 写入的缓存值(节流,最多 16ms 过期)。
* 当在 React 提交后内容增长的 useLayoutEffect 中需要新值时使用。
* 稍微更昂贵(原生 Yoga 调用)。
*/
getFreshScrollHeight: () => number;
/**
* 获取视口高度。
*/
getViewportHeight: () => number;
/**
* 第一个可见内容行padding 内)的绝对屏幕缓冲区行号。
* 用于拖动滚动边缘检测。
*/
getViewportTop: () => number;
/**
* 当滚动固定在底部时为 true。
* 由 scrollToBottom、初始 stickyScroll 属性、
* 以及位置跟随触发时渲染器设置scrollTop 在 prevMax内容增长
* 由 scrollTo/scrollBy 清除。
* "在底部"的稳定信号,不依赖于布局值
*(不同于 scrollTop+viewportH >= scrollHeight
*/
isSticky: () => boolean;
/**
* 订阅命令式滚动变更scrollTo/scrollBy/scrollToBottom
* 不会为 Ink 渲染器完成的 stickyScroll 更新触发——那些
* 发生在 React 提交后 Ink 的渲染阶段。
* 关心 sticky 情况的调用者应将"在底部"作为后备。
*/
subscribe: (listener: () => void) => () => void;
/**
* 将渲染时 scrollTop 限制设置为当前已挂载子节点的覆盖范围。
* 由 useVirtualScroll 在计算其范围后调用;
* render-node-to-output 将 scrollTop 限制在 [min, max] 内,
* 以便竞争超过 React 异步重渲染的突发 scrollTo
* 调用显示已挂载内容的边缘而非空白占位符。
* 传入 undefined 以禁用sticky、冷启动
*/
setClampBounds: (min: number | undefined, max: number | undefined) => void;
};
/**
* 从 Styles 中排除 textWrap、overflow、overflowX、overflowY
* 并添加 ScrollBox 特定的额外属性。
*/
export type ScrollBoxProps = Except<Styles, 'textWrap' | 'overflow' | 'overflowX' | 'overflowY'> & {
ref?: Ref<ScrollBoxHandle>;
/**
* 为 true 时,内容增长时自动将滚动位置固定到底部。
* 手动通过 scrollTo/scrollBy 取消设置以打破粘性。
*/
stickyScroll?: boolean;
};
/**
* 具有 `overflow: scroll` 和命令式滚动 API 的 Box。
*
* 子节点以其完整的 Yoga 计算高度在受限容器内布局。
* 在渲染时只有与可见窗口scrollTop..scrollTop+height相交的
* 子节点被渲染(视口裁剪)。内容通过 -scrollTop 平移并裁剪到盒子边界。
*
* 在全屏受限高度根Ink 树内效果最佳。
*/
function ScrollBox({
children,
ref,
stickyScroll,
...style
}: PropsWithChildren<ScrollBoxProps>): React.ReactNode {
const domRef = useRef<DOMElement>(null);
// scrollTo/scrollBy 绕过 React它们直接修改 DOM 节点上的 scrollTop
// 标记其为脏,并直接调用根的节流 scheduleRender。
// Ink 渲染器从节点读取 scrollTop——无需 React 状态,
// 每次滚轮事件也没有 reconciler 开销。
// microtask 延迟将输入批次中的多个 scrollBy 调用
//(通过 discreteUpdates合并为一次渲染——
// 否则 scheduleRender 的前缘会在第一个事件上触发,
// 然后后续事件才会修改 scrollTop。
// scrollToBottom 仍会强制 React 渲染sticky 是属性观察的,
// 没有纯 DOM 路径。
const [, forceRender] = useState(0);
const listenersRef = useRef(new Set<() => void>());
const renderQueuedRef = useRef(false);
const notify = () => {
for (const l of listenersRef.current) l();
};
function scrollMutated(el: DOMElement): void {
// 通知后台间隔IDE 轮询、LSP 轮询、GCS 获取、孤立检查)
// 跳过其下一个 tick——它们竞争事件循环
// 并在滚动排空期间造成了 1402ms 最大帧间隔。
markScrollActivity();
markDirty(el);
markCommitStart();
notify();
if (renderQueuedRef.current) return;
renderQueuedRef.current = true;
queueMicrotask(() => {
renderQueuedRef.current = false;
scheduleRenderFrom(el);
});
}
useImperativeHandle(
ref,
(): ScrollBoxHandle => ({
scrollTo(y: number) {
const el = domRef.current;
if (!el) return;
// 显式 false 覆盖 DOM 属性,以便手动滚动
// 打破粘性。渲染代码检查 ?? 优先级。
el.stickyScroll = false;
el.pendingScrollDelta = undefined;
el.scrollAnchor = undefined;
el.scrollTop = Math.max(0, Math.floor(y));
scrollMutated(el);
},
scrollToElement(el: DOMElement, offset = 0) {
const box = domRef.current;
if (!box) return;
box.stickyScroll = false;
box.pendingScrollDelta = undefined;
box.scrollAnchor = {
el,
offset
};
scrollMutated(box);
},
scrollBy(dy: number) {
const el = domRef.current;
if (!el) return;
el.stickyScroll = false;
// 滚轮输入取消任何进行中的锚点查找——用户覆盖。
el.scrollAnchor = undefined;
// 在 pendingScrollDelta 中累积;渲染器以封顶速率排出,
// 以便快速滑动显示中间帧。纯累积器:
// 向上滚动后跟向下滚动自然抵消。
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy);
scrollMutated(el);
},
scrollToBottom() {
const el = domRef.current;
if (!el) return;
el.pendingScrollDelta = undefined;
el.stickyScroll = true;
markDirty(el);
notify();
forceRender(n => n + 1);
},
getScrollTop() {
return domRef.current?.scrollTop ?? 0;
},
getPendingDelta() {
// 已累积但尚未排出的增量。useVirtualScroll 需要
// 此值来挂载并集 [已提交, 已提交+待处理] 范围——
// 否则中间排干帧会找不到子节点(空白)。
return domRef.current?.pendingScrollDelta ?? 0;
},
getScrollHeight() {
return domRef.current?.scrollHeight ?? 0;
},
getFreshScrollHeight() {
const content = domRef.current?.childNodes[0] as DOMElement | undefined;
return (
content?.yogaNode?.getComputedHeight() ??
domRef.current?.scrollHeight ??
0
);
},
getViewportHeight() {
return domRef.current?.scrollViewportHeight ?? 0;
},
getViewportTop() {
return domRef.current?.scrollViewportTop ?? 0;
},
isSticky() {
const el = domRef.current;
if (!el) return false;
return el.stickyScroll ?? Boolean(el.attributes['stickyScroll']);
},
subscribe(listener: () => void) {
listenersRef.current.add(listener);
return () => listenersRef.current.delete(listener);
},
setClampBounds(min, max) {
const el = domRef.current;
if (!el) return;
el.scrollClampMin = min;
el.scrollClampMax = max;
}
}),
// notify/scrollMutated 是内联的(无 useCallback但仅关闭
// refs + imports——稳定。空 deps 避免在每次渲染时重建句柄
//(这会重新注册 ref = 抖动)。
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
// 结构外层视口overflow:scroll受限高度>
// 内层内容flexGrow:1flexShrink:0——至少填充视口
// 但为高内容增长。flexGrow:1 让子节点使用
// spacer 将元素固定在滚动区域底部。Yoga 的
// Overflow.Scroll 防止视口增长以适应内容。
// 渲染器从内容框计算 scrollHeight并基于 scrollTop
// 裁剪内容的子节点。
//
// stickyScroll 作为 DOM 属性传递(通过 ink-box 直接),
// 以便在第一次渲染时可用——ref 回调在初始
// 提交后触发,这对于第一帧来说太晚了。
return (
<ink-box
ref={el => {
domRef.current = el;
if (el) el.scrollTop ??= 0;
}}
style={{
flexWrap: 'nowrap',
flexDirection: style.flexDirection ?? 'row',
flexGrow: style.flexGrow ?? 0,
flexShrink: style.flexShrink ?? 1,
...style,
overflowX: 'scroll',
overflowY: 'scroll'
}}
{...stickyScroll ? {
stickyScroll: true
} : {}}
>
<Box flexDirection="column" flexGrow={1} flexShrink={0} width="100%">
{children}
</Box>
</ink-box>
);
}
export default ScrollBox;

View File

@@ -0,0 +1,12 @@
import React from 'react';
import Box from './Box.js';
/**
* Spacer 组件 - 弹性空间
*
* 在其包含布局的主轴上扩展的弹性空间。
* 可用于填充元素之间的所有可用空间。
*/
export default function Spacer() {
return <Box flexGrow={1} />;
}

View File

@@ -0,0 +1,53 @@
import { createContext } from 'react'
import { EventEmitter } from '../events/emitter.js'
import type { TerminalQuerier } from '../terminal-querier.js'
export type Props = {
/**
* 传递给 `render()` 的 stdin 流,位于 `options.stdin` 或默认的 `process.stdin`。
* 如果你的应用需要处理用户输入,这会很有用。
*/
readonly stdin: NodeJS.ReadStream
/**
* Ink 通过自己的 `<StdinContext>` 暴露此函数以处理 Ctrl+C这就是为什么你应该使用
* Ink 的 `setRawMode` 而不是 `process.stdin.setRawMode`。
* 如果传递给 Ink 的 `stdin` 流不支持 setRawMode此函数什么也不做。
*/
readonly setRawMode: (value: boolean) => void
/**
* 一个布尔标志,决定当前 `stdin` 是否支持 `setRawMode`。
* 使用 `setRawMode` 的组件可能希望使用 `isRawModeSupported` 在
* 不支持原始模式的环境中优雅地回退。
*/
readonly isRawModeSupported: boolean
readonly internal_exitOnCtrlC: boolean
readonly internal_eventEmitter: EventEmitter
/** 查询终端并等待响应DECRQM, OSC 11 等)。
* 仅在永远达不到的默认 context 值中为 null。*/
readonly internal_querier: TerminalQuerier | null
}
/**
* `StdinContext` 是一个 React context暴露输入流。
*/
const StdinContext = createContext<Props>({
stdin: process.stdin,
internal_eventEmitter: new EventEmitter(),
setRawMode() {},
isRawModeSupported: false,
internal_exitOnCtrlC: true,
internal_querier: null,
})
// eslint-disable-next-line custom-rules/no-top-level-side-effects
StdinContext.displayName = 'InternalStdinContext'
export default StdinContext

View File

@@ -0,0 +1,58 @@
import { c as _c } from "react/compiler-runtime";
import React, { createContext, useMemo, useSyncExternalStore } from 'react';
import { getTerminalFocused, getTerminalFocusState, subscribeTerminalFocus, type TerminalFocusState } from '../terminal-focus-state.js';
export type { TerminalFocusState };
export type TerminalFocusContextProps = {
readonly isTerminalFocused: boolean;
readonly terminalFocusState: TerminalFocusState;
};
/**
* 终端焦点上下文 - 提供终端焦点状态给子组件。
*/
const TerminalFocusContext = createContext<TerminalFocusContextProps>({
isTerminalFocused: true,
terminalFocusState: 'unknown'
});
// eslint-disable-next-line custom-rules/no-top-level-side-effects
TerminalFocusContext.displayName = 'TerminalFocusContext';
/**
* 单独的组件,这样 App.tsx 不会在焦点变化时重新渲染。
* children 是一个稳定的 prop 引用,所以它们也不会重新渲染——
* 只有使用该上下文的组件会重新渲染。
*/
export function TerminalFocusProvider(t0) {
const $ = _c(6);
const {
children
} = t0;
const isTerminalFocused = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocused);
const terminalFocusState = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocusState);
let t1;
if ($[0] !== isTerminalFocused || $[1] !== terminalFocusState) {
t1 = {
isTerminalFocused,
terminalFocusState
};
$[0] = isTerminalFocused;
$[1] = terminalFocusState;
$[2] = t1;
} else {
t1 = $[2];
}
const value = t1;
let t2;
if ($[3] !== children || $[4] !== value) {
t2 = <TerminalFocusContext.Provider value={value}>{children}</TerminalFocusContext.Provider>;
$[3] = children;
$[4] = value;
$[5] = t2;
} else {
t2 = $[5];
}
return t2;
}
export default TerminalFocusContext;

View File

@@ -0,0 +1,14 @@
import { createContext } from 'react';
/**
* 终端尺寸类型 - 包含列数和行数。
*/
export type TerminalSize = {
columns: number;
rows: number;
};
/**
* 终端尺寸上下文 - 提供终端大小信息给子组件。
*/
export const TerminalSizeContext = createContext<TerminalSize | null>(null);

View File

@@ -0,0 +1,143 @@
import type { ReactNode } from 'react';
import React from 'react';
import type { Color, Styles, TextStyles } from '../styles.js';
type BaseProps = {
/**
* 更改文本颜色。接受原始颜色值rgb、hex、ansi
*/
readonly color?: Color;
/**
* 与 `color` 相同,但用于背景。
*/
readonly backgroundColor?: Color;
/**
* 使文本斜体。
*/
readonly italic?: boolean;
/**
* 使文本加下划线。
*/
readonly underline?: boolean;
/**
* 使文本带删除线。
*/
readonly strikethrough?: boolean;
/**
* 反转背景和前景颜色。
*/
readonly inverse?: boolean;
/**
* 此属性告诉 Ink 如果文本宽度大于容器则换行或截断文本。
* 如果传入 `wrap`默认Ink 将换行文本并将其分成多行。
* 如果传入 `truncate-*`Ink 将截断文本,这会导致一行文本,其余被切断。
*/
readonly wrap?: Styles['textWrap'];
readonly children?: ReactNode;
};
/**
* 在终端中 bold 和 dim 是互斥的。
* 此类型确保你可以使用其中一个,但不能同时使用两者。
*/
type WeightProps =
| { bold?: never; dim?: never; }
| { bold: boolean; dim?: never; }
| { dim: boolean; bold?: never; };
export type Props = BaseProps & WeightProps;
const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
wrap: {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'wrap'
},
'wrap-trim': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'wrap-trim'
},
end: {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'end'
},
middle: {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'middle'
},
'truncate-end': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'truncate-end'
},
truncate: {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'truncate'
},
'truncate-middle': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'truncate-middle'
},
'truncate-start': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'truncate-start'
}
} as const;
/**
* 此组件可以显示文本,并更改其样式使其丰富多彩、加粗、下划线、斜体或删除线。
*/
export default function Text({
color,
backgroundColor,
bold,
dim,
italic = false,
underline = false,
strikethrough = false,
inverse = false,
wrap = "wrap",
children,
}: Props): React.ReactNode {
if (children === undefined || children === null) {
return null;
}
// 构建仅包含已设置属性的 textStyles 对象
const textStyles: TextStyles = {
...(color && { color }),
...(backgroundColor && { backgroundColor }),
...(dim && { dim }),
...(bold && { bold }),
...(italic && { italic }),
...(underline && { underline }),
...(strikethrough && { strikethrough }),
...(inverse && { inverse }),
};
return (
<ink-text style={memoizedStylesForWrap[wrap]} textStyles={textStyles}>
{children}
</ink-text>
);
}

View File

@@ -0,0 +1,2 @@
// 用于渲染节流和动画的共享帧间隔(~60fps
export const FRAME_INTERVAL_MS = 16

View File

@@ -0,0 +1,510 @@
import type { FocusManager } from './focus.js'
import { createLayoutNode } from './layout/engine.js'
import type { LayoutNode } from './layout/node.js'
import { LayoutDisplay, LayoutMeasureMode } from './layout/node.js'
import measureText from './measure-text.js'
import { addPendingClear, nodeCache } from './node-cache.js'
import squashTextNodes from './squash-text-nodes.js'
import type { Styles, TextStyles } from './styles.js'
import { expandTabs } from './tabstops.js'
import wrapText from './wrap-text.js'
type InkNode = {
parentNode: DOMElement | undefined
yogaNode?: LayoutNode
style: Styles
}
export type TextName = '#text'
export type ElementNames =
| 'ink-root'
| 'ink-box'
| 'ink-text'
| 'ink-virtual-text'
| 'ink-link'
| 'ink-progress'
| 'ink-raw-ansi'
export type NodeNames = ElementNames | TextName
// eslint-disable-next-line @typescript-eslint/naming-convention
export type DOMElement = {
nodeName: ElementNames
attributes: Record<string, DOMNodeAttribute>
childNodes: DOMNode[]
textStyles?: TextStyles
// 内部属性
onComputeLayout?: () => void
onRender?: () => void
onImmediateRender?: () => void
// 用于在 React 19 的效果双重调用测试模式中跳过空渲染
hasRenderedContent?: boolean
// 当为 true 时,此节点需要重新渲染
dirty: boolean
// 由 reconciler 的 hideInstance/unhideInstance 设置;样式更新后保留。
isHidden?: boolean
// 由 reconciler 为 capture/bubble dispatcher 设置的事件处理程序。
// 与属性分开存储,以便处理程序身份变更不会标记 dirty
// 并击败 blit 优化。
_eventHandlers?: Record<string, unknown>
// overflow: 'scroll' 盒子的滚动状态。scrollTop 是内容向下滚动的行数。
// scrollHeight/scrollViewportHeight 在渲染时计算并存储以供命令式访问。
// stickyScroll 在内容增长时自动将 scrollTop 固定到底部。
scrollTop?: number
// 尚未应用于 scrollTop 的累积滚动增量。渲染器在
// SCROLL_MAX_PER_FRAME 行/帧时排出,以便快速 flick 显示中间帧
// 而不是一个大跳跃。方向反转自然取消(纯累加器,无目标跟踪)。
pendingScrollDelta?: number
// 虚拟滚动的渲染时钳制边界。useVirtualScroll 写入
// 当前挂载子元素的覆盖范围render-node-to-output 钳制 scrollTop
// 保持在其范围内。防止 scrollTo 的直接写入与 React 的异步重新渲染竞争时出现空白屏幕——
// 渲染器不是 paint spacer空白而是保持在已挂载内容边缘
// 直到 React 赶上(下一次提交更新这些边界,钳制释放)。
// Undefined = 无钳制sticky-scroll冷启动
scrollClampMin?: number
scrollClampMax?: number
scrollHeight?: number
scrollViewportHeight?: number
scrollViewportTop?: number
stickyScroll?: boolean
// 由 ScrollBox.scrollToElement 设置render-node-to-output 读取
// el.yogaNode.getComputedTop()(新鲜的——与 scrollHeight 相同的 Yoga pass
// 并设置 scrollTop = top + offset然后清除。与在节流渲染触发时过时的
// 数字 bake in 的命令式 scrollTo(N) 不同,元素 ref 将位置读取延迟到 paint 时间。
// 一次性的。
scrollAnchor?: { el: DOMElement; offset: number }
// 仅在 ink-root 上设置。文档拥有焦点——任何节点都可以
// 通过 walking parentNode 访问它,类似浏览器的 getRootNode()。
focusManager?: FocusManager
// 在 createInstance 时捕获的 React 组件栈reconciler.ts
// 例如 ['ToolUseLoader', 'Messages', 'REPL']。仅在设置
// CLAUDE_CODE_DEBUG_REPAINTS 时填充。供 findOwnerChainAtRow 使用以将
// scrollback-diff 完全重置归因于导致它们的组件。
debugOwnerChain?: string[]
} & InkNode
export type TextNode = {
nodeName: TextName
nodeValue: string
} & InkNode
// eslint-disable-next-line @typescript-eslint/naming-convention
export type DOMNode<T = { nodeName: NodeNames }> = T extends {
nodeName: infer U
}
? U extends '#text'
? TextNode
: DOMElement
: never
// eslint-disable-next-line @typescript-eslint/naming-convention
export type DOMNodeAttribute = boolean | string | number
/**
* 创建指定类型的 DOM 节点。
*/
export const createNode = (nodeName: ElementNames): DOMElement => {
const needsYogaNode =
nodeName !== 'ink-virtual-text' &&
nodeName !== 'ink-link' &&
nodeName !== 'ink-progress'
const node: DOMElement = {
nodeName,
style: {},
attributes: {},
childNodes: [],
parentNode: undefined,
yogaNode: needsYogaNode ? createLayoutNode() : undefined,
dirty: false,
}
if (nodeName === 'ink-text') {
node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node))
} else if (nodeName === 'ink-raw-ansi') {
node.yogaNode?.setMeasureFunc(measureRawAnsiNode.bind(null, node))
}
return node
}
/**
* 将子节点追加到父节点。
*/
export const appendChildNode = (
node: DOMElement,
childNode: DOMElement,
): void => {
if (childNode.parentNode) {
removeChildNode(childNode.parentNode, childNode)
}
childNode.parentNode = node
node.childNodes.push(childNode)
if (childNode.yogaNode) {
node.yogaNode?.insertChild(
childNode.yogaNode,
node.yogaNode.getChildCount(),
)
}
markDirty(node)
}
/**
* 在指定节点前插入新子节点。
*/
export const insertBeforeNode = (
node: DOMElement,
newChildNode: DOMNode,
beforeChildNode: DOMNode,
): void => {
if (newChildNode.parentNode) {
removeChildNode(newChildNode.parentNode, newChildNode)
}
newChildNode.parentNode = node
const index = node.childNodes.indexOf(beforeChildNode)
if (index >= 0) {
// 在修改 childNodes 之前计算 yoga 索引。
// 我们不能直接使用 DOM 索引,因为某些子元素(如 ink-progress、
// ink-link、ink-virtual-text没有 yogaNodes所以 DOM 索引与 yoga 索引不匹配。
let yogaIndex = 0
if (newChildNode.yogaNode && node.yogaNode) {
for (let i = 0; i < index; i++) {
if (node.childNodes[i]?.yogaNode) {
yogaIndex++
}
}
}
node.childNodes.splice(index, 0, newChildNode)
if (newChildNode.yogaNode && node.yogaNode) {
node.yogaNode.insertChild(newChildNode.yogaNode, yogaIndex)
}
markDirty(node)
return
}
node.childNodes.push(newChildNode)
if (newChildNode.yogaNode) {
node.yogaNode?.insertChild(
newChildNode.yogaNode,
node.yogaNode.getChildCount(),
)
}
markDirty(node)
}
/**
* 从父节点移除子节点。
*/
export const removeChildNode = (
node: DOMElement,
removeNode: DOMNode,
): void => {
if (removeNode.yogaNode) {
removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode)
}
// 从已移除子树收集缓存矩形以便清除
collectRemovedRects(node, removeNode)
removeNode.parentNode = undefined
const index = node.childNodes.indexOf(removeNode)
if (index >= 0) {
node.childNodes.splice(index, 1)
}
markDirty(node)
}
/**
* 收集已移除节点的矩形信息。
* 如果被移除的节点或其任何祖先是绝对定位的,其绘制像素可能与非兄弟节点重叠——
* 需要标记以全局禁用 blit。正常流向移除只影响直接兄弟节点
* 这已经由 hasRemovedChild 处理。
*/
function collectRemovedRects(
parent: DOMElement,
removed: DOMNode,
underAbsolute = false,
): void {
if (removed.nodeName === '#text') return
const elem = removed as DOMElement
const isAbsolute = underAbsolute || elem.style.position === 'absolute'
const cached = nodeCache.get(elem)
if (cached) {
addPendingClear(parent, cached, isAbsolute)
nodeCache.delete(elem)
}
for (const child of elem.childNodes) {
collectRemovedRects(parent, child, isAbsolute)
}
}
/**
* 设置 DOM 元素的属性。
*/
export const setAttribute = (
node: DOMElement,
key: string,
value: DOMNodeAttribute,
): void => {
// 跳过 'children' - React 通过 appendChild/removeChild 处理子元素,
// 而不是属性。React 总是传递新的 children 引用,
// 所以将其作为属性跟踪会使每次渲染都标记为 dirty。
if (key === 'children') {
return
}
// 如果未更改则跳过
if (node.attributes[key] === value) {
return
}
node.attributes[key] = value
markDirty(node)
}
/**
* 设置 DOM 元素的样式。
*/
export const setStyle = (node: DOMNode, style: Styles): void => {
// 比较样式属性以避免不必要地标记 dirty。
// React 在每次渲染时创建新的样式对象,即使未更改。
if (stylesEqual(node.style, style)) {
return
}
node.style = style
markDirty(node)
}
/**
* 设置文本样式。
*/
export const setTextStyles = (
node: DOMElement,
textStyles: TextStyles,
): void => {
// 与 setStyle 相同的 dirty-check guardReact和 Text.tsx 中的 buildTextStyles
// 在每次渲染时分配新的 textStyles 对象,即使值未更改,
// 所以按值比较以避免每次 Text 重新渲染时 markDirty -> yoga 重新测量。
if (shallowEqual(node.textStyles, textStyles)) {
return
}
node.textStyles = textStyles
markDirty(node)
}
function stylesEqual(a: Styles, b: Styles): boolean {
return shallowEqual(a, b)
}
function shallowEqual<T extends object>(
a: T | undefined,
b: T | undefined,
): boolean {
// 快速路径:相同对象引用(或两者都未定义)
if (a === b) return true
if (a === undefined || b === undefined) return false
// 获取两个对象的所有键
const aKeys = Object.keys(a) as (keyof T)[]
const bKeys = Object.keys(b) as (keyof T)[]
// 不同数量的属性
if (aKeys.length !== bKeys.length) return false
// 比较每个属性
for (const key of aKeys) {
if (a[key] !== b[key]) return false
}
return true
}
/**
* 创建文本节点。
*/
export const createTextNode = (text: string): TextNode => {
const node: TextNode = {
nodeName: '#text',
nodeValue: text,
yogaNode: undefined,
parentNode: undefined,
style: {},
}
setTextNodeValue(node, text)
return node
}
/**
* 测量文本节点的尺寸。
* Ink-text 节点由 Yoga 布局系统调用以确定所需空间。
*/
const measureTextNode = function (
node: DOMNode,
width: number,
widthMode: LayoutMeasureMode,
): { width: number; height: number } {
const rawText =
node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node)
// 展开制表符进行测量(最坏情况:每个 8 个空格)。
// 实际制表符展开基于屏幕位置在 output.ts 中进行。
const text = expandTabs(rawText)
const dimensions = measureText(text, width)
// 文本适合容器,无需换行
if (dimensions.width <= width) {
return dimensions
}
// 当 <Box> 收缩子节点且布局询问此文本节点是否能放入 <1px 空间时会发生这种情况,
// 所以我们只说"不能"
if (dimensions.width >= 1 && width > 0 && width < 1) {
return dimensions
}
// 对于带有嵌入式换行符的文本(预包装内容),避免在
// 布局询问内在大小Undefined 模式)时重新包装。
// 这可以防止 min/max 大小检查期间的高度膨胀。
//
// 但是当布局提供实际约束Exactly 或 AtMost 模式)时,
// 我们必须尊重它并在该宽度下测量。否则,如果实际
// 渲染宽度小于自然宽度,文本将包裹到比布局期望的更多行,
// 导致内容被截断。
if (text.includes('\n') && widthMode === LayoutMeasureMode.Undefined) {
const effectiveWidth = Math.max(width, dimensions.width)
return measureText(text, effectiveWidth)
}
const textWrap = node.style?.textWrap ?? 'wrap'
const wrappedText = wrapText(text, width, textWrap)
return measureText(wrappedText, width)
}
// ink-raw-ansi 节点保存具有已知尺寸的预渲染 ANSI 字符串。
// 无需 stringWidth、无需换行、无需制表符展开——生产者例如 ColorDiff
// 已经包装到目标宽度,每行恰好是一个终端行。
const measureRawAnsiNode = function (node: DOMElement): {
width: number
height: number
} {
return {
width: node.attributes['rawWidth'] as number,
height: node.attributes['rawHeight'] as number,
}
}
/**
* 将节点及其所有祖先标记为 dirty 以便重新渲染。
* 如果这是文本节点,也会标记 yoga dirty 以便重新测量文本。
*/
export const markDirty = (node?: DOMNode): void => {
let current: DOMNode | undefined = node
let markedYoga = false
while (current) {
if (current.nodeName !== '#text') {
;(current as DOMElement).dirty = true
// 仅在有测量函数的叶节点上标记 yoga dirty
if (
!markedYoga &&
(current.nodeName === 'ink-text' ||
current.nodeName === 'ink-raw-ansi') &&
current.yogaNode
) {
current.yogaNode.markDirty()
markedYoga = true
}
}
current = current.parentNode
}
}
// 走到根节点并调用其 onRender节流的 scheduleRender
// 用于应触发 Ink 帧的 DOM 级变更scrollTop 变更),
// 而不通过 React 的 reconciler。与 markDirty() 配对,
// 以便渲染器知道要重新评估哪个子树。
export const scheduleRenderFrom = (node?: DOMNode): void => {
let cur: DOMNode | undefined = node
while (cur?.parentNode) cur = cur.parentNode
if (cur && cur.nodeName !== '#text') (cur as DOMElement).onRender?.()
}
/**
* 设置文本节点的文本值。
*/
export const setTextNodeValue = (node: TextNode, text: string): void => {
if (typeof text !== 'string') {
text = String(text)
}
// 如果未更改则跳过
if (node.nodeValue === text) {
return
}
node.nodeValue = text
markDirty(node)
}
function isDOMElement(node: DOMElement | TextNode): node is DOMElement {
return node.nodeName !== '#text'
}
// 在释放前递归清除 yogaNode 引用。
// freeRecursive() 释放节点及其所有子节点,所以我们必须清除
// 所有 yogaNode 引用以防止悬空指针。
export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => {
if ('childNodes' in node) {
for (const child of node.childNodes) {
clearYogaNodeReferences(child)
}
}
node.yogaNode = undefined
}
/**
* 找到对屏幕行 `y` 处的内容负责的 React 组件栈。
*
* DFS 遍历 DOM 树,累积 yoga 偏移量。返回其边界框包含 `y` 的
* 最深层节点的 debugOwnerChain。当 log-update 触发完全重置时,
* 从 ink.tsx 调用此函数,以将闪烁归因于其来源。
*
* 仅在设置 CLAUDE_CODE_DEBUG_REPAINTS 时有用(否则链为 undefined返回 [])。
*/
export function findOwnerChainAtRow(root: DOMElement, y: number): string[] {
let best: string[] = []
walk(root, 0)
return best
function walk(node: DOMElement, offsetY: number): void {
const yoga = node.yogaNode
if (!yoga || yoga.getDisplay() === LayoutDisplay.None) return
const top = offsetY + yoga.getComputedTop()
const height = yoga.getComputedHeight()
if (y < top || y >= top + height) return
if (node.debugOwnerChain) best = node.debugOwnerChain
for (const child of node.childNodes) {
if (isDOMElement(child)) walk(child, top)
}
}
}

View File

@@ -0,0 +1,38 @@
import { Event } from './event.js'
/**
* 鼠标点击事件。仅在鼠标追踪启用时(即在 <AlternateScreen> 内)
* 且左键释放无拖拽时触发。
*
* 从最深的命中节点向上冒泡到 parentNode。调用
* stopImmediatePropagation() 以阻止祖先的 onClick 触发。
*/
export class ClickEvent extends Event {
/** 点击的 0 索引屏幕列 */
readonly col: number
/** 点击的 0 索引屏幕行 */
readonly row: number
/**
* 相对于当前处理器 Box 的点击列col - box.x
* 在每个处理器触发前由 dispatchClick 重新计算,因此容器上的 onClick
* 看到的是相对于该容器的坐标,而不是点击落到的任何
* 子元素的坐标。
*/
localCol = 0
/** 相对于当前处理器 Box 的点击行row - box.y。 */
localRow = 0
/**
* 如果点击的单元格没有可见内容(在
* 屏幕缓冲区中未写入 —— 两个填充词都是 0为 true。
* 处理器可以检查这个来忽略文本右侧空白空间的点击,
* 这样对终端空白空间的意外点击不会切换状态。
*/
readonly cellIsBlank: boolean
constructor(col: number, row: number, cellIsBlank: boolean) {
super()
this.col = col
this.row = row
this.cellIsBlank = cellIsBlank
}
}

View File

@@ -0,0 +1,231 @@
import {
ContinuousEventPriority,
DefaultEventPriority,
DiscreteEventPriority,
NoEventPriority,
} from 'react-reconciler/constants.js'
import { logError } from '../../utils/log.js'
import { HANDLER_FOR_EVENT } from './event-handlers.js'
import type { EventTarget, TerminalEvent } from './terminal-event.js'
// --
type DispatchListener = {
node: EventTarget
handler: (event: TerminalEvent) => void
phase: 'capturing' | 'at_target' | 'bubbling'
}
function getHandler(
node: EventTarget,
eventType: string,
capture: boolean,
): ((event: TerminalEvent) => void) | undefined {
const handlers = node._eventHandlers
if (!handlers) return undefined
const mapping = HANDLER_FOR_EVENT[eventType]
if (!mapping) return undefined
const propName = capture ? mapping.capture : mapping.bubble
if (!propName) return undefined
return handlers[propName] as ((event: TerminalEvent) => void) | undefined
}
/**
* 收集所有按调度顺序排列的事件监听器。
*
* 使用 react-dom 的两阶段累积模式:
* - 从目标到根遍历
* - 捕获处理器被前置unshift→ 根优先
* - 气泡处理器被追加push→ 目标优先
*
* 结果:[root-cap, ..., parent-cap, target-cap, target-bub, parent-bub, ..., root-bub]
*/
function collectListeners(
target: EventTarget,
event: TerminalEvent,
): DispatchListener[] {
const listeners: DispatchListener[] = []
let node: EventTarget | undefined = target
while (node) {
const isTarget = node === target
const captureHandler = getHandler(node, event.type, true)
const bubbleHandler = getHandler(node, event.type, false)
if (captureHandler) {
listeners.unshift({
node,
handler: captureHandler,
phase: isTarget ? 'at_target' : 'capturing',
})
}
if (bubbleHandler && (event.bubbles || isTarget)) {
listeners.push({
node,
handler: bubbleHandler,
phase: isTarget ? 'at_target' : 'bubbling',
})
}
node = node.parentNode
}
return listeners
}
/**
* 使用传播控制执行收集的监听器。
*
* 在每个处理器之前,调用 event._prepareForTarget(node) 以便事件
* 子类可以执行每个节点的设置。
*/
function processDispatchQueue(
listeners: DispatchListener[],
event: TerminalEvent,
): void {
let previousNode: EventTarget | undefined
for (const { node, handler, phase } of listeners) {
if (event._isImmediatePropagationStopped()) {
break
}
if (event._isPropagationStopped() && node !== previousNode) {
break
}
event._setEventPhase(phase)
event._setCurrentTarget(node)
event._prepareForTarget(node)
try {
handler(event)
} catch (error) {
logError(error)
}
previousNode = node
}
}
// --
/**
* 将终端事件类型映射到 React 调度优先级。
* 镜像 react-dom 的 getEventPriority() 开关。
*/
function getEventPriority(eventType: string): number {
switch (eventType) {
case 'keydown':
case 'keyup':
case 'click':
case 'focus':
case 'blur':
case 'paste':
return DiscreteEventPriority as number
case 'resize':
case 'scroll':
case 'mousemove':
return ContinuousEventPriority as number
default:
return DefaultEventPriority as number
}
}
// --
type DiscreteUpdates = <A, B>(
fn: (a: A, b: B) => boolean,
a: A,
b: B,
c: undefined,
d: undefined,
) => boolean
/**
* 拥有事件调度状态和捕获/冒泡调度循环。
*
* reconciler 主机配置读取 currentEvent 和 currentUpdatePriority
* 来实现 resolveUpdatePriority、resolveEventType 和
* resolveEventTimeStamp —— 镜像 react-dom 主机配置读取
* ReactDOMSharedInternals 和 window.event 的方式。
*
* discreteUpdates 在构造后注入(由 InkReconciler以打破导入循环。
*/
export class Dispatcher {
currentEvent: TerminalEvent | null = null
currentUpdatePriority: number = DefaultEventPriority as number
discreteUpdates: DiscreteUpdates | null = null
/**
* 从当前调度的事件推断事件优先级。
* 当没有设置显式优先级时,由 reconciler 主机配置的 resolveUpdatePriority 调用。
*/
resolveEventPriority(): number {
if (this.currentUpdatePriority !== (NoEventPriority as number)) {
return this.currentUpdatePriority
}
if (this.currentEvent) {
return getEventPriority(this.currentEvent.type)
}
return DefaultEventPriority as number
}
/**
* 通过捕获和冒泡阶段调度事件。
* 如果 preventDefault() 未被调用则返回 true。
*/
dispatch(target: EventTarget, event: TerminalEvent): boolean {
const previousEvent = this.currentEvent
this.currentEvent = event
try {
event._setTarget(target)
const listeners = collectListeners(target, event)
processDispatchQueue(listeners, event)
event._setEventPhase('none')
event._setCurrentTarget(null)
return !event.defaultPrevented
} finally {
this.currentEvent = previousEvent
}
}
/**
* 使用离散(同步)优先级调度。
* 用于用户发起的事件:键盘、点击、焦点、粘贴。
*/
dispatchDiscrete(target: EventTarget, event: TerminalEvent): boolean {
if (!this.discreteUpdates) {
return this.dispatch(target, event)
}
return this.discreteUpdates(
(t, e) => this.dispatch(t, e),
target,
event,
undefined,
undefined,
)
}
/**
* 使用连续优先级调度。
* 用于高频事件:调整大小、滚动、鼠标移动。
*/
dispatchContinuous(target: EventTarget, event: TerminalEvent): boolean {
const previousPriority = this.currentUpdatePriority
try {
this.currentUpdatePriority = ContinuousEventPriority as number
return this.dispatch(target, event)
} finally {
this.currentUpdatePriority = previousPriority
}
}
}

View File

@@ -0,0 +1,39 @@
import { EventEmitter as NodeEventEmitter } from 'events'
import { Event } from './event.js'
// 类似于 Node.js 内置的 EventEmitter但也认识我们的 Event 类,
// 因此 `emit` 尊重 `stopImmediatePropagation()`。
export class EventEmitter extends NodeEventEmitter {
constructor() {
super()
// 禁用默认的 maxListeners 警告。在 React 中,许多组件
// 可以合法地监听同一事件(例如 useInput hooks
// 默认的 10 个限制会导致虚假警告。
this.setMaxListeners(0)
}
override emit(type: string | symbol, ...args: unknown[]): boolean {
// 对于 `error`,委托给 node 处理,因为它不像普通事件那样处理
if (type === 'error') {
return super.emit(type, ...args)
}
const listeners = this.rawListeners(type)
if (listeners.length === 0) {
return false
}
const ccEvent = args[0] instanceof Event ? args[0] : null
for (const listener of listeners) {
listener.apply(this, args)
if (ccEvent?.didStopImmediatePropagation()) {
break
}
}
return true
}
}

View File

@@ -0,0 +1,73 @@
import type { ClickEvent } from './click-event.js'
import type { FocusEvent } from './focus-event.js'
import type { KeyboardEvent } from './keyboard-event.js'
import type { PasteEvent } from './paste-event.js'
import type { ResizeEvent } from './resize-event.js'
type KeyboardEventHandler = (event: KeyboardEvent) => void
type FocusEventHandler = (event: FocusEvent) => void
type PasteEventHandler = (event: PasteEvent) => void
type ResizeEventHandler = (event: ResizeEvent) => void
type ClickEventHandler = (event: ClickEvent) => void
type HoverEventHandler = () => void
/**
* Box 和其他宿主组件上事件处理器的 Props。
*
* 遵循 React/DOM 命名约定:
* - onEventName: 气泡阶段处理器
* - onEventNameCapture: 捕获阶段处理器
*/
export type EventHandlerProps = {
onKeyDown?: KeyboardEventHandler
onKeyDownCapture?: KeyboardEventHandler
onFocus?: FocusEventHandler
onFocusCapture?: FocusEventHandler
onBlur?: FocusEventHandler
onBlurCapture?: FocusEventHandler
onPaste?: PasteEventHandler
onPasteCapture?: PasteEventHandler
onResize?: ResizeEventHandler
onClick?: ClickEventHandler
onMouseEnter?: HoverEventHandler
onMouseLeave?: HoverEventHandler
}
/**
* 反向查找:事件类型字符串 → 处理器 prop 名称。
* 供调度器用于每个节点 O(1) 处理器查找。
*/
export const HANDLER_FOR_EVENT: Record<
string,
{ bubble?: keyof EventHandlerProps; capture?: keyof EventHandlerProps }
> = {
keydown: { bubble: 'onKeyDown', capture: 'onKeyDownCapture' },
focus: { bubble: 'onFocus', capture: 'onFocusCapture' },
blur: { bubble: 'onBlur', capture: 'onBlurCapture' },
paste: { bubble: 'onPaste', capture: 'onPasteCapture' },
resize: { bubble: 'onResize' },
click: { bubble: 'onClick' },
}
/**
* 所有事件处理器 prop 名称的集合,供 reconciler 检测
* 事件 props 并将它们存储在 _eventHandlers 中而不是属性中。
*/
export const EVENT_HANDLER_PROPS = new Set<string>([
'onKeyDown',
'onKeyDownCapture',
'onFocus',
'onFocusCapture',
'onBlur',
'onBlurCapture',
'onPaste',
'onPasteCapture',
'onResize',
'onClick',
'onMouseEnter',
'onMouseLeave',
])

View File

@@ -0,0 +1,11 @@
export class Event {
private _didStopImmediatePropagation = false
didStopImmediatePropagation(): boolean {
return this._didStopImmediatePropagation
}
stopImmediatePropagation(): void {
this._didStopImmediatePropagation = true
}
}

View File

@@ -0,0 +1,20 @@
import { type EventTarget, TerminalEvent } from './terminal-event.js'
/**
* 组件焦点变化的焦点事件。
*
* 当焦点在元素之间移动时分派。'focus' 在新聚焦的元素上触发,
* 'blur' 在先前聚焦的元素上触发。两者都冒泡,匹配 react-dom 对
* focusin/focusout 语义的使用,以便父组件可以观察后代焦点变化。
*/
export class FocusEvent extends TerminalEvent {
readonly relatedTarget: EventTarget | null
constructor(
type: 'focus' | 'blur',
relatedTarget: EventTarget | null = null,
) {
super(type, { bubbles: true, cancelable: false })
this.relatedTarget = relatedTarget
}
}

View File

@@ -0,0 +1,205 @@
import { nonAlphanumericKeys, type ParsedKey } from '../parse-keypress.js'
import { Event } from './event.js'
export type Key = {
upArrow: boolean
downArrow: boolean
leftArrow: boolean
rightArrow: boolean
pageDown: boolean
pageUp: boolean
wheelUp: boolean
wheelDown: boolean
home: boolean
end: boolean
return: boolean
escape: boolean
ctrl: boolean
shift: boolean
fn: boolean
tab: boolean
backspace: boolean
delete: boolean
meta: boolean
super: boolean
}
function parseKey(keypress: ParsedKey): [Key, string] {
const key: Key = {
upArrow: keypress.name === 'up',
downArrow: keypress.name === 'down',
leftArrow: keypress.name === 'left',
rightArrow: keypress.name === 'right',
pageDown: keypress.name === 'pagedown',
pageUp: keypress.name === 'pageup',
wheelUp: keypress.name === 'wheelup',
wheelDown: keypress.name === 'wheeldown',
home: keypress.name === 'home',
end: keypress.name === 'end',
return: keypress.name === 'return',
escape: keypress.name === 'escape',
fn: keypress.fn,
ctrl: keypress.ctrl,
shift: keypress.shift,
tab: keypress.name === 'tab',
backspace: keypress.name === 'backspace',
delete: keypress.name === 'delete',
// `parseKeypress` 将 \u001B\u001B[Ameta + 上箭头)解析为 meta = false
// 但 option = true所以我们需要在这里考虑这个
// 以避免 Ink 中的破坏性更改。
// TODO(vadimdemedes):考虑在下一个主要版本中删除这个。
meta: keypress.meta || keypress.name === 'escape' || keypress.option,
// SupermacOS 上的 Cmd / Win 键)—— 仅通过 kitty 键盘
// 协议 CSI u 序列到达。与 metaAlt/Option不同
// 因此像 cmd+c 这样的绑定可以与 opt+c 分开表达。
super: keypress.super,
}
let input = keypress.ctrl ? keypress.name : keypress.sequence
// 处理未定义的输入情况
if (input === undefined) {
input = ''
}
// 当 ctrl 被设置时keypress.name 对于空格是字面单词 "space"。
// 转换为实际空格字符以与 CSI u 分支保持一致
//(将 'space' → ' '。没有这个ctrl+space 会泄漏字面
// 单词 "space" 到文本输入中。
if (keypress.ctrl && input === 'space') {
input = ' '
}
// 抑制未被识别为功能键解析的转义序列
//(由 FN_KEY_RE 匹配)但键名映射中没有名称的序列。
// 示例ESC[25~Windows 上的 F13/Right AltESC[26~F14等。
// 没有这个ESC 前缀在下面被剥离,剩余部分(例如,
// "[25~")泄漏为输入中的字面文本。
if (keypress.code && !keypress.name) {
input = ''
}
// 抑制无 ESC 的 SGR 鼠标片段。当重型 React 提交阻塞
// 事件循环超过 App 的 50ms NORMAL_TIMEOUT 刷新时,跨
// stdin 块分片的 CSI 将其缓冲的 ESC 刷新为孤立的 Escape 键,
// 后续到达为 name='' 的文本标记 —— 穿过 parseKeypress 所有
// 基于 ESC 的正则表达式和下面的 nonAlphanumericKeys
// 清除name 是假值)。片段然后泄漏到提示符中作为
// 字面 `[<64;74;16M`。这与上面的 F13 防护是相同的防御性接收;
// 底层分词器刷新竞态在此层之上。
if (!keypress.name && /^\[<\d+;\d+;\d+[Mm]/.test(input)) {
input = ''
}
// 如果在 `parseKeypress` 之后仍有 meta 前缀,则剥离它
// TODO(vadimdemedes):在下一个主要版本中删除这个。
if (input.startsWith('\u001B')) {
input = input.slice(1)
}
// 跟踪是否已作为特殊序列处理
// 将 input 转换为键名CSI u 或应用程序小键盘模式)。
// 对于这些,我们不想用 nonAlphanumericKeys 检查清除 input。
let processedAsSpecialSequence = false
// 处理 CSI u 序列Kitty 键盘协议):剥离 ESC 后,
// 我们得到 "[codepoint;modifieru"(例如 "[98;3u" 表示 Alt+b
// 使用解析的键名进行输入处理。要求 [ 后有数字
// —— 真正的 CSI u 总是 [<digits>…u而裸露的 startsWith('[')
// 在第 85 行Cy = 85+32 = 'u')对 X10 鼠标误判,
// 通过 processedAsSpecialSequence 将字面文本 "mouse" 泄漏到提示符中。
if (/^\[\d/.test(input) && input.endsWith('u')) {
if (!keypress.name) {
// 未映射的 Kitty 功能键(大小写锁定 57358F13-F35小键盘导航
// 裸修饰符等)—— keycodeToName() 返回 undefined。吞下
// 原始 "[57358u" 不要泄漏到提示符中。见 #38781。
input = ''
} else {
// 'space' → ' ''escape' → ''key.escape 携带它;
// processedAsSpecialSequence 绕过下面的 nonAlphanumericKeys
// 清除,所以我们必须在这里明确处理它);
// 否则使用键名。
input =
keypress.name === 'space'
? ' '
: keypress.name === 'escape'
? ''
: keypress.name
}
processedAsSpecialSequence = true
}
// 处理 xterm modifyOtherKeys 序列:剥离 ESC 后,
// 我们得到 "[27;modifier;keycode~"(例如 "[27;3;98~" 表示 Alt+b
// 与 CSI u 相同的提取 —— 没有这个,可打印字符键码(单字母
// 名称)跳过 nonAlphanumericKeys 清除并泄漏 "[27;..." 作为输入。
if (input.startsWith('[27;') && input.endsWith('~')) {
if (!keypress.name) {
// 未映射的 modifyOtherKeys 键码 —— 为与上面的 CSI u
// 处理器保持一致而吞下。今天实际上不可触发xterm
// modifyOtherKeys 只发送 ASCII 键码,都已映射),但
// 防止未来的终端行为。
input = ''
} else {
input =
keypress.name === 'space'
? ' '
: keypress.name === 'escape'
? ''
: keypress.name
}
processedAsSpecialSequence = true
}
// 处理应用程序小键盘模式序列:剥离 ESC 后,
// 我们得到 "O<letter>"(例如 "Op" 表示小键盘 0"Oy" 表示小键盘 9
// 使用解析的键名(数字字符)进行输入处理。
if (
input.startsWith('O') &&
input.length === 2 &&
keypress.name &&
keypress.name.length === 1
) {
input = keypress.name
processedAsSpecialSequence = true
}
// 清除非字母数字键的输入(箭头、功能键等)
// 对于 CSI u 和应用程序小键盘模式序列跳过此步骤,因为
// 那些已经被转换为适当的输入字符。
if (
!processedAsSpecialSequence &&
keypress.name &&
nonAlphanumericKeys.includes(keypress.name)
) {
input = ''
}
// 为大写字母A-Z设置 shift=true
// 必须检查它实际上是一个字母,而不仅仅是任何未被 toUpperCase 改变的字符
if (
input.length === 1 &&
typeof input[0] === 'string' &&
input[0] >= 'A' &&
input[0] <= 'Z'
) {
key.shift = true
}
return [key, input]
}
export class InputEvent extends Event {
readonly keypress: ParsedKey
readonly key: Key
readonly input: string
constructor(keypress: ParsedKey) {
super()
const [key, input] = parseKey(keypress)
this.keypress = keypress
this.key = key
this.input = input
}
}

View File

@@ -0,0 +1,51 @@
import type { ParsedKey } from '../parse-keypress.js'
import { TerminalEvent } from './terminal-event.js'
/**
* 通过捕获/冒泡在 DOM 树中分派的键盘事件。
*
* 遵循浏览器 KeyboardEvent 语义:对于可打印键,`key` 是字面字符
*'a'、'3'、' '、'/'),对于特殊键是多位名称
*'down'、'return'、'escape'、'f1')。惯用的
* 可打印字符检查是 `e.key.length === 1`。
*/
export class KeyboardEvent extends TerminalEvent {
readonly key: string
readonly ctrl: boolean
readonly shift: boolean
readonly meta: boolean
readonly superKey: boolean
readonly fn: boolean
constructor(parsedKey: ParsedKey) {
super('keydown', { bubbles: true, cancelable: true })
this.key = keyFromParsed(parsedKey)
this.ctrl = parsedKey.ctrl
this.shift = parsedKey.shift
this.meta = parsedKey.meta || parsedKey.option
this.superKey = parsedKey.super
this.fn = parsedKey.fn
}
}
function keyFromParsed(parsed: ParsedKey): string {
const seq = parsed.sequence ?? ''
const name = parsed.name ?? ''
// Ctrl 组合sequence 是一个控制字节(\x03 表示 ctrl+cname 是
// 字母。浏览器报告 e.key === 'c' 且 e.ctrlKey === true。
if (parsed.ctrl) return name
// 单个可打印字符(空格到 ~,加上任何高于 ASCII 的):
// 使用字面字符。浏览器报告 e.key === '3',而不是 'Digit3'。
if (seq.length === 1) {
const code = seq.charCodeAt(0)
if (code >= 0x20 && code !== 0x7f) return seq
}
// 特殊键箭头、F 键、return、tab、escape 等sequence 是
// 转义序列(\x1b[B或控制字节\r、\t所以使用
// 解析的名称。浏览器报告 e.key === 'ArrowDown'。
return name || seq
}

View File

@@ -0,0 +1,107 @@
import { Event } from './event.js'
type EventPhase = 'none' | 'capturing' | 'at_target' | 'bubbling'
type TerminalEventInit = {
bubbles?: boolean
cancelable?: boolean
}
/**
* 具有 DOM 风格传播的所有终端事件的基类。
*
* 扩展 Event以便现有事件类型ClickEvent, InputEvent,
* TerminalFocusEvent共享一个公共祖先以后可以迁移。
*
* 镜像浏览器的 Event APItarget, currentTarget, eventPhase,
* stopPropagation(), preventDefault(), timeStamp。
*/
export class TerminalEvent extends Event {
readonly type: string
readonly timeStamp: number
readonly bubbles: boolean
readonly cancelable: boolean
private _target: EventTarget | null = null
private _currentTarget: EventTarget | null = null
private _eventPhase: EventPhase = 'none'
private _propagationStopped = false
private _defaultPrevented = false
constructor(type: string, init?: TerminalEventInit) {
super()
this.type = type
this.timeStamp = performance.now()
this.bubbles = init?.bubbles ?? true
this.cancelable = init?.cancelable ?? true
}
get target(): EventTarget | null {
return this._target
}
get currentTarget(): EventTarget | null {
return this._currentTarget
}
get eventPhase(): EventPhase {
return this._eventPhase
}
get defaultPrevented(): boolean {
return this._defaultPrevented
}
stopPropagation(): void {
this._propagationStopped = true
}
override stopImmediatePropagation(): void {
super.stopImmediatePropagation()
this._propagationStopped = true
}
preventDefault(): void {
if (this.cancelable) {
this._defaultPrevented = true
}
}
// -- 供调度器使用的内部 setter
/** @internal */
_setTarget(target: EventTarget): void {
this._target = target
}
/** @internal */
_setCurrentTarget(target: EventTarget | null): void {
this._currentTarget = target
}
/** @internal */
_setEventPhase(phase: EventPhase): void {
this._eventPhase = phase
}
/** @internal */
_isPropagationStopped(): boolean {
return this._propagationStopped
}
/** @internal */
_isImmediatePropagationStopped(): boolean {
return this.didStopImmediatePropagation()
}
/**
* 供子类在每个处理器触发前对每个节点进行设置的钩子。
* 默认是空操作。
*/
_prepareForTarget(_target: EventTarget): void {}
}
export type EventTarget = {
parentNode: EventTarget | undefined
_eventHandlers?: Record<string, unknown>
}

View File

@@ -0,0 +1,19 @@
import { Event } from './event.js'
export type TerminalFocusEventType = 'terminalfocus' | 'terminalblur'
/**
* 当终端窗口获得或失去焦点时触发的事件。
*
* 使用 DECSET 1004 焦点报告 - 终端发送:
* - CSI I (\x1b[I) 当终端获得焦点
* - CSI O (\x1b[O) 当终端失去焦点
*/
export class TerminalFocusEvent extends Event {
readonly type: TerminalFocusEventType
constructor(type: TerminalFocusEventType) {
super()
this.type = type
}
}

View File

@@ -0,0 +1,181 @@
import type { DOMElement } from './dom.js'
import { FocusEvent } from './events/focus-event.js'
const MAX_FOCUS_STACK = 32
/**
* FocusManager - Ink 终端 UI 的类 DOM 焦点管理器
*
* 纯状态管理 —— 追踪 activeElement 和焦点栈。
* 不引用树结构;调用者在需要树遍历时传入根节点。
*
* 存储在根 DOMElement 上,以便任何节点可以通过 walking
* parentNode 访问它(类似浏览器的 node.ownerDocument
*/
export class FocusManager {
activeElement: DOMElement | null = null
private dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean
private enabled = true
private focusStack: DOMElement[] = []
constructor(
dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean,
) {
this.dispatchFocusEvent = dispatchFocusEvent
}
focus(node: DOMElement): void {
if (node === this.activeElement) return
if (!this.enabled) return
const previous = this.activeElement
if (previous) {
// 推送前去重,防止 Tab 循环时无限增长
const idx = this.focusStack.indexOf(previous)
if (idx !== -1) this.focusStack.splice(idx, 1)
this.focusStack.push(previous)
if (this.focusStack.length > MAX_FOCUS_STACK) this.focusStack.shift()
this.dispatchFocusEvent(previous, new FocusEvent('blur', node))
}
this.activeElement = node
this.dispatchFocusEvent(node, new FocusEvent('focus', previous))
}
blur(): void {
if (!this.activeElement) return
const previous = this.activeElement
this.activeElement = null
this.dispatchFocusEvent(previous, new FocusEvent('blur', null))
}
/**
* 当节点从树中移除时由 reconciler 调用。
* 处理被移除节点及其在已移除子树中的任何焦点后代。
* 分发 blur 并从栈中恢复焦点。
*/
handleNodeRemoved(node: DOMElement, root: DOMElement): void {
// 从栈中移除该节点及其后代
this.focusStack = this.focusStack.filter(
n => n !== node && isInTree(n, root),
)
// 检查 activeElement 是否是被移除的节点或其后代
if (!this.activeElement) return
if (this.activeElement !== node && isInTree(this.activeElement, root)) {
return
}
const removed = this.activeElement
this.activeElement = null
this.dispatchFocusEvent(removed, new FocusEvent('blur', null))
// 恢复焦点到最近仍挂载的元素
while (this.focusStack.length > 0) {
const candidate = this.focusStack.pop()!
if (isInTree(candidate, root)) {
this.activeElement = candidate
this.dispatchFocusEvent(candidate, new FocusEvent('focus', removed))
return
}
}
}
handleAutoFocus(node: DOMElement): void {
this.focus(node)
}
handleClickFocus(node: DOMElement): void {
const tabIndex = node.attributes['tabIndex']
if (typeof tabIndex !== 'number') return
this.focus(node)
}
enable(): void {
this.enabled = true
}
disable(): void {
this.enabled = false
}
focusNext(root: DOMElement): void {
this.moveFocus(1, root)
}
focusPrevious(root: DOMElement): void {
this.moveFocus(-1, root)
}
private moveFocus(direction: 1 | -1, root: DOMElement): void {
if (!this.enabled) return
const tabbable = collectTabbable(root)
if (tabbable.length === 0) return
const currentIndex = this.activeElement
? tabbable.indexOf(this.activeElement)
: -1
const nextIndex =
currentIndex === -1
? direction === 1
? 0
: tabbable.length - 1
: (currentIndex + direction + tabbable.length) % tabbable.length
const next = tabbable[nextIndex]
if (next) {
this.focus(next)
}
}
}
function collectTabbable(root: DOMElement): DOMElement[] {
const result: DOMElement[] = []
walkTree(root, result)
return result
}
function walkTree(node: DOMElement, result: DOMElement[]): void {
const tabIndex = node.attributes['tabIndex']
if (typeof tabIndex === 'number' && tabIndex >= 0) {
result.push(node)
}
for (const child of node.childNodes) {
if (child.nodeName !== '#text') {
walkTree(child, result)
}
}
}
function isInTree(node: DOMElement, root: DOMElement): boolean {
let current: DOMElement | undefined = node
while (current) {
if (current === root) return true
current = current.parentNode
}
return false
}
/**
* 向上走到根节点并返回。根节点是持有 FocusManager 的节点——
* 类似浏览器的 node.getRootNode()。
*/
export function getRootNode(node: DOMElement): DOMElement {
let current: DOMElement | undefined = node
while (current) {
if (current.focusManager) return current
current = current.parentNode
}
throw new Error('Node is not in a tree with a FocusManager')
}
/**
* 向上走到根节点并返回其 FocusManager。
* 类似浏览器的 node.ownerDocument —— 焦点属于根节点。
*/
export function getFocusManager(node: DOMElement): FocusManager {
return getRootNode(node).focusManager!
}

View File

@@ -0,0 +1,124 @@
import type { Cursor } from './cursor.js'
import type { Size } from './layout/geometry.js'
import type { ScrollHint } from './render-node-to-output.js'
import {
type CharPool,
createScreen,
type HyperlinkPool,
type Screen,
type StylePool,
} from './screen.js'
export type Frame = {
readonly screen: Screen
readonly viewport: Size
readonly cursor: Cursor
/** DECSTBM 滚动优化提示(仅 alt-screen否则为 null。 */
readonly scrollHint?: ScrollHint | null
/** ScrollBox 有待处理的 pendingScrollDelta — 调度另一帧。 */
readonly scrollDrainPending?: boolean
}
export function emptyFrame(
rows: number,
columns: number,
stylePool: StylePool,
charPool: CharPool,
hyperlinkPool: HyperlinkPool,
): Frame {
return {
screen: createScreen(0, 0, stylePool, charPool, hyperlinkPool),
viewport: { width: columns, height: rows },
cursor: { x: 0, y: 0, visible: true },
}
}
export type FlickerReason = 'resize' | 'offscreen' | 'clear'
export type FrameEvent = {
durationMs: number
/** 阶段细分(毫秒)+ 补丁计数。当 ink 实例
* 有帧时序检测启用时填充(通过 onFrame 接线)。*/
phases?: {
/** createRenderer 输出DOM → yoga 布局 → 屏幕缓冲区 */
renderer: number
/** LogUpdate.render():屏幕 diff → Patch[](此 PR 优化的热路径) */
diff: number
/** optimize():补丁合并/去重 */
optimize: number
/** writeDiffToTerminal():序列化补丁 → ANSI → stdout */
write: number
/** 优化前的补丁计数(此帧变化量的代理) */
patches: number
/** yoga calculateLayout() 时间(在 resetAfterCommit 中运行,在 onRender 之前) */
yoga: number
/** React reconcile 时间scrollMutated → resetAfterCommit。无提交则为 0。 */
commit: number
/** 此帧的 layoutNode() 调用(递归,包括缓存命中返回) */
yogaVisited: number
/** measureFunc文本换行/宽度)调用 — 昂贵部分 */
yogaMeasured: number
/** 通过 _hasL 单槽缓存的提前返回 */
yogaCacheHits: number
/** 存活的 yoga Node 实例总数create - free。增长 = 泄漏。 */
yogaLive: number
}
flickers: Array<{
desiredHeight: number
availableHeight: number
reason: FlickerReason
}>
}
export type Patch =
| { type: 'stdout'; content: string }
| { type: 'clear'; count: number }
| {
type: 'clearTerminal'
reason: FlickerReason
// 当滚动缓冲区 diff 触发重置时由 log-update 填充。
// ink.tsx 使用 triggerY 与 findOwnerChainAtRow 来将
// 闪烁归因于其源 React 组件。
debug?: { triggerY: number; prevLine: string; nextLine: string }
}
| { type: 'cursorHide' }
| { type: 'cursorShow' }
| { type: 'cursorMove'; x: number; y: number }
| { type: 'cursorTo'; col: number }
| { type: 'carriageReturn' }
| { type: 'hyperlink'; uri: string }
// 来自 StylePool.transition() 的预序列化样式转换字符串 —
// 由 (fromId, toId) 缓存,热身后零分配。
| { type: 'styleStr'; str: string }
export type Diff = Patch[]
/**
* 根据当前帧和前一帧确定是否应清除屏幕。
* 返回清除的原因,如果不需要清除则返回 undefined。
*
* 屏幕清除在以下情况下触发:
* 1. 终端已调整大小(视口尺寸改变)→ 'resize'
* 2. 当前帧屏幕高度超过可用终端行 → 'offscreen'
* 3. 前一帧屏幕高度超过可用终端行 → 'offscreen'
*/
export function shouldClearScreen(
prevFrame: Frame,
frame: Frame,
): FlickerReason | undefined {
const didResize =
frame.viewport.height !== prevFrame.viewport.height ||
frame.viewport.width !== prevFrame.viewport.width
if (didResize) {
return 'resize'
}
const currentFrameOverflows = frame.screen.height >= frame.viewport.height
const previousFrameOverflowed =
prevFrame.screen.height >= prevFrame.viewport.height
if (currentFrameOverflows || previousFrameOverflowed) {
return 'offscreen'
}
return undefined
}

View File

@@ -0,0 +1,26 @@
import { LayoutEdge, type LayoutNode } from './layout/node.js'
/**
* 返回 yoga 节点的内容宽度(计算宽度减去内边距和边框)。
*
* 警告:可能返回比父容器更宽的值。
* 在列方向 flex 父级中width 是横轴 —— align-items:
* stretch 从不将子元素收缩到其内在大小以下,所以文本
* 节点溢出(标准 CSS 行为。Yoga 以两次 pass 测量叶节点:
* AtMost pass 确定宽度Exactly pass 确定高度。
* getComputedWidth() 反映更宽的 AtMost 结果,而
* getComputedHeight() 反映更窄的 Exactly 结果。
* 使用此函数进行换行的调用者应限制为实际可用的屏幕空间,
* 以便渲染的行数与布局高度保持一致。
*/
const getMaxWidth = (yogaNode: LayoutNode): number => {
return (
yogaNode.getComputedWidth() -
yogaNode.getComputedPadding(LayoutEdge.Left) -
yogaNode.getComputedPadding(LayoutEdge.Right) -
yogaNode.getComputedBorder(LayoutEdge.Left) -
yogaNode.getComputedBorder(LayoutEdge.Right)
)
}
export default getMaxWidth

View File

@@ -0,0 +1,129 @@
import type { DOMElement } from './dom.js'
import { ClickEvent } from './events/click-event.js'
import type { EventHandlerProps } from './events/event-handlers.js'
import { nodeCache } from './node-cache.js'
/**
* 找到其渲染矩形包含 (col, row) 的最深的 DOM 元素。
*
* 使用 renderNodeToOutput 填充的 nodeCache — 矩形在屏幕
* 坐标中,所有偏移(包括 scrollTop 平移)已应用。
* 子元素以逆序遍历,以便后期兄弟元素(画在上面)获胜。
* 不在 nodeCache 中的节点(此帧未渲染,或缺少 yogaNode
* 及其子树被跳过。
*
* 即使命中节点没有 onClick 也返回命中节点 — dispatchClick 通过
* parentNode 向上查找处理器。
*/
export function hitTest(
node: DOMElement,
col: number,
row: number,
): DOMElement | null {
const rect = nodeCache.get(node)
if (!rect) return null
if (
col < rect.x ||
col >= rect.x + rect.width ||
row < rect.y ||
row >= rect.y + rect.height
) {
return null
}
// 后期兄弟元素画在上面;逆序遍历返回最顶层命中。
for (let i = node.childNodes.length - 1; i >= 0; i--) {
const child = node.childNodes[i]!
if (child.nodeName === '#text') continue
const hit = hitTest(child, col, row)
if (hit) return hit
}
return node
}
/**
* 在 (col, row) 处对根进行命中测试,并将 ClickEvent 从最深的
* 包含节点向上冒泡到 parentNode。只有具有 onClick 处理器的节点触发。
* 当处理器调用 stopImmediatePropagation() 时停止。如果至少一个
* onClick 处理器触发则返回 true。
*/
export function dispatchClick(
root: DOMElement,
col: number,
row: number,
cellIsBlank = false,
): boolean {
let target: DOMElement | undefined = hitTest(root, col, row) ?? undefined
if (!target) return false
// 点击转焦点:找到最近的 focusable 祖先并聚焦它。
// root 始终是 ink-root它拥有 FocusManager。
if (root.focusManager) {
let focusTarget: DOMElement | undefined = target
while (focusTarget) {
if (typeof focusTarget.attributes['tabIndex'] === 'number') {
root.focusManager.handleClickFocus(focusTarget)
break
}
focusTarget = focusTarget.parentNode
}
}
const event = new ClickEvent(col, row, cellIsBlank)
let handled = false
while (target) {
const handler = target._eventHandlers?.onClick as
| ((event: ClickEvent) => void)
| undefined
if (handler) {
handled = true
const rect = nodeCache.get(target)
if (rect) {
event.localCol = col - rect.x
event.localRow = row - rect.y
}
handler(event)
if (event.didStopImmediatePropagation()) return true
}
target = target.parentNode
}
return handled
}
/**
* 当指针移动时触发 onMouseEnter/onMouseLeave。类似于 DOM
* mouseenter/mouseleave不冒泡 — 在子元素之间移动不会
* 在父元素上重新触发。从命中节点向上遍历,收集每个
* 有悬停处理器的祖先;与先前悬停集合对比;
* 对退出的节点触发 leave对进入的节点触发 enter。
*
* 就地改变 `hovered`以便调用者App 实例)可以跨调用保留它。
* 当命中为空时清除集合(光标移动到未渲染的间隙或根矩形之外)。
*/
export function dispatchHover(
root: DOMElement,
col: number,
row: number,
hovered: Set<DOMElement>,
): void {
const next = new Set<DOMElement>()
let node: DOMElement | undefined = hitTest(root, col, row) ?? undefined
while (node) {
const h = node._eventHandlers as EventHandlerProps | undefined
if (h?.onMouseEnter || h?.onMouseLeave) next.add(node)
node = node.parentNode
}
for (const old of hovered) {
if (!next.has(old)) {
hovered.delete(old)
// 跳过已分离节点上的处理器(在鼠标事件之间移除)
if (old.parentNode) {
;(old._eventHandlers as EventHandlerProps | undefined)?.onMouseLeave?.()
}
}
}
for (const n of next) {
if (!hovered.has(n)) {
hovered.add(n)
;(n._eventHandlers as EventHandlerProps | undefined)?.onMouseEnter?.()
}
}
}

View File

@@ -0,0 +1,55 @@
import { useContext, useEffect, useState } from 'react'
import { ClockContext } from '../components/ClockContext.js'
import type { DOMElement } from '../dom.js'
import { useTerminalViewport } from './use-terminal-viewport.js'
/**
* 用于同步动画的 Hook在屏幕外时暂停。
*
* 返回附加到动画元素的 ref 和当前动画时间。
* 所有实例共享同一个时钟,因此动画保持同步。
* 时钟仅在至少存在一个 keepAlive 订阅者时运行。
*
* 传递 `null` 可暂停——取消订阅时钟,因此不会触发 tick。
* 时间冻结在最后一个值,并在再次传递数字时从当前时钟时间恢复。
*
* @param intervalMs - 更新频率null 表示暂停
* @returns [ref, time] - 附加到元素的 ref以毫秒为单位的已用时间
*
* @example
* function Spinner() {
* const [ref, time] = useAnimationFrame(120)
* const frame = Math.floor(time / 120) % FRAMES.length
* return <Box ref={ref}>{FRAMES[frame]}</Box>
* }
*
* 时钟在终端失焦时自动放慢,因此消费者不需要处理焦点状态。
*/
export function useAnimationFrame(
intervalMs: number | null = 16,
): [ref: (element: DOMElement | null) => void, time: number] {
const clock = useContext(ClockContext)
const [viewportRef, { isVisible }] = useTerminalViewport()
const [time, setTime] = useState(() => clock?.now() ?? 0)
const active = isVisible && intervalMs !== null
useEffect(() => {
if (!clock || !active) return
let lastUpdate = clock.now()
const onChange = (): void => {
const now = clock.now()
if (now - lastUpdate >= intervalMs!) {
lastUpdate = now
setTime(now)
}
}
// keepAlive: true — 可见动画驱动时钟
return clock.subscribe(onChange, true)
}, [clock, intervalMs, active])
return [viewportRef, time]
}

View File

@@ -0,0 +1,8 @@
import { useContext } from 'react'
import AppContext from '../components/AppContext.js'
/**
* `useApp` 是一个 React Hook用于访问手动退出应用的方法卸载
*/
const useApp = () => useContext(AppContext)
export default useApp

View File

@@ -0,0 +1,68 @@
import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
import CursorDeclarationContext from '../components/CursorDeclarationContext.js'
import type { DOMElement } from '../dom.js'
/**
* 声明终端光标在每帧后应停放的位置。
*
* 终端模拟器在物理光标位置呈现 IME 预编辑文本,
* 屏幕阅读器/屏幕放大镜跟踪原生光标——所以将其停放在
* 文本输入的插入符号处使 CJK 输入显示为内联,
* 并让辅助工具跟随输入。
*
* 返回附加到包含输入的 Box 的 ref 回调。
* 声明的(行,列)相对于该 Box 的 nodeCache 矩形解释
*(由 renderNodeToOutput 填充)。
*
* 时序ref 附加和 useLayoutEffect 都在 React 的布局阶段触发——
* 在 resetAfterCommit 调用 scheduleRender 之后。
* scheduleRender 通过 queueMicrotask 延迟 onRender
* 所以 onRender 在布局效果提交之后运行,并在第一帧读取新的声明
*(无单键延迟)。测试环境使用 onImmediateRender同步无微任务
* 所以测试通过在渲染后显式调用 ink.onRender() 来补偿。
*/
export function useDeclaredCursor({
line,
column,
active,
}: {
line: number
column: number
active: boolean
}): (element: DOMElement | null) => void {
const setCursorDeclaration = useContext(CursorDeclarationContext)
const nodeRef = useRef<DOMElement | null>(null)
const setNode = useCallback((node: DOMElement | null) => {
nodeRef.current = node
}, [])
// 当 active 时,无条件设置。当 inactive 时,有条件地清除
//(仅当当前声明的节点是我们的时)。节点身份检查处理两个危险:
// 1. 另一个地方的 memo()ed active 实例(例如 memo'd Footer 中的搜索输入)
// 不会在此提交上重新渲染——在此重新渲染的非活动实例不得覆盖它。
// 2. 兄弟交接(菜单焦点在列表项之间移动)——当焦点移动方向与兄弟顺序相反时,
// 新非活动项的效果在新活动项的 set 之后运行。
// 没有节点检查,它会覆盖。
// 无依赖数组:必须在每次提交时重新声明,以便活动实例在其
// 他实例的卸载清理或兄弟交接后重新声明。
useLayoutEffect(() => {
const node = nodeRef.current
if (active && node) {
setCursorDeclaration({ relativeX: column, relativeY: line, node })
} else {
setCursorDeclaration(null, node)
}
})
// 卸载时清除(有条件——届时另一个实例可能拥有它)。
// 具有空依赖的单独效果,以便清理只触发一次——而不是在每次
// line/column 变更时,这在提交之间暂时为 null。
useLayoutEffect(() => {
return () => {
setCursorDeclaration(null, nodeRef.current)
}
}, [setCursorDeclaration])
return setNode
}

View File

@@ -0,0 +1,92 @@
import { useEffect, useLayoutEffect } from 'react'
import { useEventCallback } from 'usehooks-ts'
import type { InputEvent, Key } from '../events/input-event.js'
import useStdin from './use-stdin.js'
type Handler = (input: string, key: Key, event: InputEvent) => void
type Options = {
/**
* 启用或禁用用户输入的捕获。
* 当同时使用多个 useInput hook 时很有用,以避免多次处理相同的输入。
*
* @default true
*/
isActive?: boolean
}
/**
* 此 hook 用于处理用户输入。
* 这是使用 `StdinContext` 并监听 `data` 事件的更方便的替代方案。
* 你传递给 `useInput` 的回调在用户输入任何内容时为每个字符调用。
* 但是,如果用户粘贴文本且超过一个字符,回调将仅调用一次,
* 整个字符串将作为 `input` 传递。
*
* ```
* import {useInput} from 'ink';
*
* const UserInput = () => {
* useInput((input, key) => {
* if (input === 'q') {
* // 退出程序
* }
*
* if (key.leftArrow) {
* // 按下左箭头键
* }
* });
*
* return …
* };
* ```
*/
const useInput = (inputHandler: Handler, options: Options = {}) => {
const { setRawMode, internal_exitOnCtrlC, internal_eventEmitter } = useStdin()
// 使用 useLayoutEffect不是 useEffect以便在 React 提交阶段同步启用原始模式,
// 在 render() 返回之前。使用 useEffect原始模式设置通过 React 的调度器
// 延迟到下一个事件循环 tick将终端保持在 cooked 模式 —— 按键回显
// 且光标可见,直到效果触发。
useLayoutEffect(() => {
if (options.isActive === false) {
return
}
setRawMode(true)
return () => {
setRawMode(false)
}
}, [options.isActive, setRawMode])
// 在挂载时注册一次监听器,以便其在 EventEmitter 的
// 监听器数组中的槽位稳定。如果 isActive 在效果的 deps 中,
// 监听器会在 false→true 时重新追加,移动到
// 在其非活动期间注册的监听器之后 —— 破坏
// stopImmediatePropagation() 排序。useEventCallback 保持
// 引用稳定,同时从闭包读取最新的 isActive/inputHandler
//(它通过 useLayoutEffect 同步,所以编译器安全)。
const handleData = useEventCallback((event: InputEvent) => {
if (options.isActive === false) {
return
}
const { input, key } = event
// 如果应用不应在 Ctrl+C 时退出,则让输入监听器处理它
// 注意在发出事件时discreteUpdates 在 App 级别调用,
// 所以所有监听器已经在高优先级更新上下文中。
if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) {
inputHandler(input, key, event)
}
})
useEffect(() => {
internal_eventEmitter?.on('input', handleData)
return () => {
internal_eventEmitter?.removeListener('input', handleData)
}
}, [internal_eventEmitter, handleData])
}
export default useInput

View File

@@ -0,0 +1,65 @@
import { useContext, useEffect, useRef, useState } from 'react'
import { ClockContext } from '../components/ClockContext.js'
/**
* 返回时钟时间,按给定间隔更新。
* 订阅为非 keepAlive——不会自行保持时钟活动
* 但每当 keepAlive 订阅者(例如微调器)驱动时钟时会更新。
*
* 使用它来从共享时钟驱动纯时间计算(微光位置、帧索引)。
*/
export function useAnimationTimer(intervalMs: number): number {
const clock = useContext(ClockContext)
const [time, setTime] = useState(() => clock?.now() ?? 0)
useEffect(() => {
if (!clock) return
let lastUpdate = clock.now()
const onChange = (): void => {
const now = clock.now()
if (now - lastUpdate >= intervalMs) {
lastUpdate = now
setTime(now)
}
}
return clock.subscribe(onChange, false)
}, [clock, intervalMs])
return time
}
/**
* 由共享时钟支持的 Interval hook。
*
* 与 `usehooks-ts` 中的 `useInterval` 不同(它创建自己的 setInterval
* 这个 hook 搭乘单个共享时钟,因此所有计时器合并为一次唤醒。
* 传递 `null` 作为 intervalMs 可暂停。
*/
export function useInterval(
callback: () => void,
intervalMs: number | null,
): void {
const callbackRef = useRef(callback)
callbackRef.current = callback
const clock = useContext(ClockContext)
useEffect(() => {
if (!clock || intervalMs === null) return
let lastUpdate = clock.now()
const onChange = (): void => {
const now = clock.now()
if (now - lastUpdate >= intervalMs) {
lastUpdate = now
callbackRef.current()
}
}
return clock.subscribe(onChange, false)
}, [clock, intervalMs])
}

View File

@@ -0,0 +1,50 @@
import { useContext, useMemo } from 'react'
import StdinContext from '../components/StdinContext.js'
import type { DOMElement } from '../dom.js'
import instances from '../instances.js'
import type { MatchPosition } from '../render-to-screen.js'
/**
* 在 Ink 实例上设置搜索高亮查询。非空 → 所有
* 可见出现都在下一帧反转SGR 7屏幕缓冲区覆盖
* 与选择相同的损坏机制)。空 → 清除。
*
* 这是屏幕空间高亮——它匹配渲染的文本,而不是源消息文本。
* 适用于任何可见内容bash 输出、文件路径、错误消息),
* 无论它们在消息树中来自何处。在源中匹配但
* 在渲染中被截断/省略的查询不会高亮;这是可接受的——我们高亮你看到的。
*/
export function useSearchHighlight(): {
setQuery: (query: string) => void
/** 将现有 DOM 子树(来自主树)绘制到其自然高度的新 Screen扫描。
* 元素相对位置(行 0 = 元素顶部)。零上下文重复——
* 该元素正是使用所有真实提供程序构建的那个。 */
scanElement: (el: DOMElement) => MatchPosition[]
/** 基于位置的当前高亮。每一帧在 positions[currentIdx] + rowOffset 处写入黄色。
* 扫描高亮(在所有匹配上反转)仍在运行——这覆盖在上面。
* rowOffset 跟踪滚动位置保持稳定消息相对。null 清除。 */
setPositions: (
state: {
positions: MatchPosition[]
rowOffset: number
currentIdx: number
} | null,
) => void
} {
useContext(StdinContext) // 挂载到 App 子树以符合 hook 规则
const ink = instances.get(process.stdout)
return useMemo(() => {
if (!ink) {
return {
setQuery: () => {},
scanElement: () => [],
setPositions: () => {},
}
}
return {
setQuery: (query: string) => ink.setSearchHighlight(query),
scanElement: (el: DOMElement) => ink.scanElementSubtree(el),
setPositions: state => ink.setSearchPositions(state),
}
}, [ink])
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useLayoutEffect } from 'react'
import { useEventCallback } from 'usehooks-ts'
import type { InputEvent, Key } from '../events/input-event.js'
import useStdin from './use-stdin.js'
type Handler = (input: string, key: Key, event: InputEvent) => void
type Options = {
/**
* 启用或禁用用户输入捕获。
* 当同时使用多个 useInput hook 时很有用,以避免多次处理同一输入。
*
* @default true
*/
isActive?: boolean
}
/**
* 此 hook 用于处理用户输入。
* 这是使用 `StdinContext` 并监听 `data` 事件的更方便的替代方案。
* 你传递给 `useInput` 的回调在用户输入任何内容时为每个字符调用。
* 但是,如果用户粘贴文本且超过一个字符,回调只会调用一次,
* 整个字符串将作为 `input` 传递。
*
* ```
* import {useInput} from 'ink';
*
* const UserInput = () => {
* useInput((input, key) => {
* if (input === 'q') {
* // 退出程序
* }
*
* if (key.leftArrow) {
* // 按下左箭头键
* }
* });
*
* return …
* };
* ```
*/
const useInput = (inputHandler: Handler, options: Options = {}) => {
const { setRawMode, internal_exitOnCtrlC, internal_eventEmitter } = useStdin()
// 使用 useLayoutEffect而不是 useEffect以便在 React 的提交阶段
// 同步启用原始模式,在 render() 返回之前。使用 useEffect 时,原始
// 模式设置会通过 React 的调度程序延迟到下一个事件循环 tick
// 使终端处于cooked模式——按键回显且光标可见直到效果触发。
useLayoutEffect(() => {
if (options.isActive === false) {
return
}
setRawMode(true)
return () => {
setRawMode(false)
}
}, [options.isActive, setRawMode])
// 在挂载时注册一次监听器,这样它在 EventEmitter 的监听器数组中的槽是稳定的。
// 如果 isActive 在效果的依赖中,监听器会在 false→true 时重新追加,
// 移动到在它非活动时注册的其他监听器之后——破坏
// stopImmediatePropagation() 顺序。useEventCallback 保持引用稳定,
// 同时从闭包中读取最新的 isActive/inputHandler
//(它通过 useLayoutEffect 同步,所以是编译器安全的)。
const handleData = useEventCallback((event: InputEvent) => {
if (options.isActive === false) {
return
}
const { input, key } = event
// 如果应用不应在 Ctrl+C 时退出,则让输入监听器处理它
// 注意:在发出事件时在 App 级别调用 discreteUpdates
// 所以所有监听器已经在高优先级更新上下文中。
if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) {
inputHandler(input, key, event)
}
})
useEffect(() => {
internal_eventEmitter?.on('input', handleData)
return () => {
internal_eventEmitter?.removeListener('input', handleData)
}
}, [internal_eventEmitter, handleData])
}
export default useInput

View File

@@ -0,0 +1,8 @@
import { useContext } from 'react'
import StdinContext from '../components/StdinContext.js'
/**
* `useStdin` 是一个 React Hook用于访问标准输入流。
*/
const useStdin = () => useContext(StdinContext)
export default useStdin

View File

@@ -0,0 +1,72 @@
import { useContext, useEffect, useRef } from 'react'
import {
CLEAR_TAB_STATUS,
supportsTabStatus,
tabStatus,
wrapForMultiplexer,
} from '../termio/osc.js'
import type { Color } from '../termio/types.js'
import { TerminalWriteContext } from '../useTerminalNotification.js'
export type TabStatusKind = 'idle' | 'busy' | 'waiting'
const rgb = (r: number, g: number, b: number): Color => ({
type: 'rgb',
r,
g,
b,
})
// 根据 OSC 21337 使用指南的建议映射。
const TAB_STATUS_PRESETS: Record<
TabStatusKind,
{ indicator: Color; status: string; statusColor: Color }
> = {
idle: {
indicator: rgb(0, 215, 95),
status: 'Idle',
statusColor: rgb(136, 136, 136),
},
busy: {
indicator: rgb(255, 149, 0),
status: 'Working…',
statusColor: rgb(255, 149, 0),
},
waiting: {
indicator: rgb(95, 135, 255),
status: 'Waiting',
statusColor: rgb(95, 135, 255),
},
}
/**
* 声明性地设置标签页状态指示器OSC 21337
*
* 向标签页侧边栏发出彩色点 + 简短状态文本。
* 不支持 OSC 21337 的终端会静默丢弃序列,所以无条件调用是安全的。
* 为 tmux/screen 穿透包装。
*
* 传递 `null` 可选择退出。如果之前设置了状态,过渡到
* `null` 会发出 CLEAR_TAB_STATUS因此在会话中途关闭不会留下
* 过时的点。进程退出清理由 ink.tsx 的卸载路径处理。
*/
export function useTabStatus(kind: TabStatusKind | null): void {
const writeRaw = useContext(TerminalWriteContext)
const prevKindRef = useRef<TabStatusKind | null>(null)
useEffect(() => {
// 当 kind 从非 null 过渡到 null 时(例如用户在会话中途关闭 showStatusInTerminalTab
// 清除过时的点。
if (kind === null) {
if (prevKindRef.current !== null && writeRaw && supportsTabStatus()) {
writeRaw(wrapForMultiplexer(CLEAR_TAB_STATUS))
}
prevKindRef.current = null
return
}
prevKindRef.current = kind
if (!writeRaw || !supportsTabStatus()) return
writeRaw(wrapForMultiplexer(tabStatus(TAB_STATUS_PRESETS[kind])))
}, [kind, writeRaw])
}

View File

@@ -0,0 +1,15 @@
import { useContext } from 'react'
import TerminalFocusContext from '../components/TerminalFocusContext.js'
/**
* Hook 用于检查终端是否有焦点。
*
* 使用 DECSET 1004 焦点报告——终端在获得或失去焦点时发送转义序列。
* 这些由 Ink 自动处理并从 useInput 中过滤。
*
* @returns 如果终端有焦点(或焦点状态未知)则返回 true
*/
export function useTerminalFocus(): boolean {
const { isTerminalFocused } = useContext(TerminalFocusContext)
return isTerminalFocused
}

View File

@@ -0,0 +1,30 @@
import { useContext, useEffect } from 'react'
import stripAnsi from 'strip-ansi'
import { OSC, osc } from '../termio/osc.js'
import { TerminalWriteContext } from '../useTerminalNotification.js'
/**
* 声明性地设置终端标签页/窗口标题。
*
* 传递字符串以设置标题。ANSI 转义序列自动剥离,
* 因此调用者不需要了解终端编码。传递 `null` 可选择退出——
* hook 变为无操作,不触动终端标题。
*
* 在 Windows 上,使用 `process.title`(经典 conhost 不支持 OSC
* 在其他地方,通过 Ink 的 stdout 写入 OSC 0设置 title+icon
*/
export function useTerminalTitle(title: string | null): void {
const writeRaw = useContext(TerminalWriteContext)
useEffect(() => {
if (title === null || !writeRaw) return
const clean = stripAnsi(title)
if (process.platform === 'win32') {
process.title = clean
} else {
writeRaw(osc(OSC.SET_TITLE_AND_ICON, clean))
}
}, [title, writeRaw])
}

View File

@@ -0,0 +1,104 @@
import { useContext, useMemo, useSyncExternalStore } from 'react'
import StdinContext from '../components/StdinContext.js'
import instances from '../instances.js'
import {
type FocusMove,
type SelectionState,
shiftAnchor,
} from '../selection.js'
/**
* 访问 Ink 实例上的文本选择操作(仅全屏)。
* 当全屏模式禁用时返回无操作函数。
*/
export function useSelection(): {
copySelection: () => string
/** 复制而不清除高亮(用于选择时复制)。 */
copySelectionNoClear: () => string
clearSelection: () => void
hasSelection: () => boolean
/** 读取原始可变选择状态(用于拖动滚动)。 */
getState: () => SelectionState | null
/** 订阅选择变更(开始/更新/完成/清除)。 */
subscribe: (cb: () => void) => () => void
/** 按 dRow 移动锚点行,限制在 [minRow, maxRow] 内。 */
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
/** 同时移动锚点和焦点 dRow键盘滚动整个选择
* 跟踪内容)。被限制的点会将其列重置到满宽边缘,
* 因为它们的内容由 captureScrolledRows 捕获。
* 从 ink 实例读取 screen.width 以获取列重置边界。 */
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
/** 键盘选择扩展shift+箭头):移动焦点,锚点固定。
* 左/右跨行环绕;上/下在视口边缘限制。 */
moveFocus: (move: FocusMove) => void
/** 从即将滚出视口的行捕获文本(在调用
* scrollBy 之前调用,这样屏幕缓冲区仍有离开的行)。 */
captureScrolledRows: (
firstRow: number,
lastRow: number,
side: 'above' | 'below',
) => void
/** 设置选择高亮背景色(主题管道;实色背景
* 替换旧的 SGR-7 反转,以便在选择下语法高亮仍可读。
* 在挂载时调用一次 + 主题变更时调用。) */
setSelectionBgColor: (color: string) => void
} {
// 通过 stdout 查找 Ink 实例——与实例映射相同的模式。
// StdinContext 可用(它总是被提供),而 Ink 实例
// 由 stdout 键控,我们可以从 process.stdout 获取,
// 因为实际上每个进程只有一个 Ink 实例。
useContext(StdinContext) // 挂载到 App 子树以符合 hook 规则
const ink = instances.get(process.stdout)
// 使用 useMemo 以便调用者可以安全地在依赖数组中使用返回值。
// ink 是每个 stdout 的单例——在渲染之间稳定。
return useMemo(() => {
if (!ink) {
return {
copySelection: () => '',
copySelectionNoClear: () => '',
clearSelection: () => {},
hasSelection: () => false,
getState: () => null,
subscribe: () => () => {},
shiftAnchor: () => {},
shiftSelection: () => {},
moveFocus: () => {},
captureScrolledRows: () => {},
setSelectionBgColor: () => {},
}
}
return {
copySelection: () => ink.copySelection(),
copySelectionNoClear: () => ink.copySelectionNoClear(),
clearSelection: () => ink.clearTextSelection(),
hasSelection: () => ink.hasTextSelection(),
getState: () => ink.selection,
subscribe: (cb: () => void) => ink.subscribeToSelectionChange(cb),
shiftAnchor: (dRow: number, minRow: number, maxRow: number) =>
shiftAnchor(ink.selection, dRow, minRow, maxRow),
shiftSelection: (dRow, minRow, maxRow) =>
ink.shiftSelectionForScroll(dRow, minRow, maxRow),
moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move),
captureScrolledRows: (firstRow, lastRow, side) =>
ink.captureScrolledRows(firstRow, lastRow, side),
setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color),
}
}, [ink])
}
const NO_SUBSCRIBE = () => () => {}
const ALWAYS_FALSE = () => false
/**
* 反应性选择存在状态。当创建或清除文本
* 选择时重新渲染调用者。在全屏模式外始终返回 false
*(选择仅在 alt-screen 中可用)。
*/
export function useHasSelection(): boolean {
useContext(StdinContext)
const ink = instances.get(process.stdout)
return useSyncExternalStore(
ink ? ink.subscribeToSelectionChange : NO_SUBSCRIBE,
ink ? ink.hasTextSelection : ALWAYS_FALSE,
)
}

View File

@@ -0,0 +1,10 @@
// 存储 Ink 的所有实例instance.js以确保连续的 render() 调用
// 使用相同的 Ink 实例而不创建新实例
//
// 此映射必须存储在单独的文件中,因为 render.js 创建实例,
// 但 instance.js 应该在卸载时从映射中删除自身
import type Ink from './ink.js'
const instances = new Map<NodeJS.WriteStream, Ink>()
export default instances

View File

@@ -0,0 +1,9 @@
import type { LayoutNode } from './node.js'
import { createYogaLayoutNode } from './yoga.js'
/**
* 创建布局节点的工厂函数。
*/
export function createLayoutNode(): LayoutNode {
return createYogaLayoutNode()
}

View File

@@ -0,0 +1,152 @@
// --
// 布局引擎Yoga的适配器接口
export const LayoutEdge = {
All: 'all',
Horizontal: 'horizontal',
Vertical: 'vertical',
Left: 'left',
Right: 'right',
Top: 'top',
Bottom: 'bottom',
Start: 'start',
End: 'end',
} as const
export type LayoutEdge = (typeof LayoutEdge)[keyof typeof LayoutEdge]
export const LayoutGutter = {
All: 'all',
Column: 'column',
Row: 'row',
} as const
export type LayoutGutter = (typeof LayoutGutter)[keyof typeof LayoutGutter]
export const LayoutDisplay = {
Flex: 'flex',
None: 'none',
} as const
export type LayoutDisplay = (typeof LayoutDisplay)[keyof typeof LayoutDisplay]
export const LayoutFlexDirection = {
Row: 'row',
RowReverse: 'row-reverse',
Column: 'column',
ColumnReverse: 'column-reverse',
} as const
export type LayoutFlexDirection =
(typeof LayoutFlexDirection)[keyof typeof LayoutFlexDirection]
export const LayoutAlign = {
Auto: 'auto',
Stretch: 'stretch',
FlexStart: 'flex-start',
Center: 'center',
FlexEnd: 'flex-end',
} as const
export type LayoutAlign = (typeof LayoutAlign)[keyof typeof LayoutAlign]
export const LayoutJustify = {
FlexStart: 'flex-start',
Center: 'center',
FlexEnd: 'flex-end',
SpaceBetween: 'space-between',
SpaceAround: 'space-around',
SpaceEvenly: 'space-evenly',
} as const
export type LayoutJustify = (typeof LayoutJustify)[keyof typeof LayoutJustify]
export const LayoutWrap = {
NoWrap: 'nowrap',
Wrap: 'wrap',
WrapReverse: 'wrap-reverse',
} as const
export type LayoutWrap = (typeof LayoutWrap)[keyof typeof LayoutWrap]
export const LayoutPositionType = {
Relative: 'relative',
Absolute: 'absolute',
} as const
export type LayoutPositionType =
(typeof LayoutPositionType)[keyof typeof LayoutPositionType]
export const LayoutOverflow = {
Visible: 'visible',
Hidden: 'hidden',
Scroll: 'scroll',
} as const
export type LayoutOverflow =
(typeof LayoutOverflow)[keyof typeof LayoutOverflow]
export type LayoutMeasureFunc = (
width: number,
widthMode: LayoutMeasureMode,
) => { width: number; height: number }
export const LayoutMeasureMode = {
Undefined: 'undefined',
Exactly: 'exactly',
AtMost: 'at-most',
} as const
export type LayoutMeasureMode =
(typeof LayoutMeasureMode)[keyof typeof LayoutMeasureMode]
export type LayoutNode = {
// 树
insertChild(child: LayoutNode, index: number): void
removeChild(child: LayoutNode): void
getChildCount(): number
getParent(): LayoutNode | null
// 布局计算
calculateLayout(width?: number, height?: number): void
setMeasureFunc(fn: LayoutMeasureFunc): void
unsetMeasureFunc(): void
markDirty(): void
// 布局读取(布局后)
getComputedLeft(): number
getComputedTop(): number
getComputedWidth(): number
getComputedHeight(): number
getComputedBorder(edge: LayoutEdge): number
getComputedPadding(edge: LayoutEdge): number
// 样式设置器
setWidth(value: number): void
setWidthPercent(value: number): void
setWidthAuto(): void
setHeight(value: number): void
setHeightPercent(value: number): void
setHeightAuto(): void
setMinWidth(value: number): void
setMinWidthPercent(value: number): void
setMinHeight(value: number): void
setMinHeightPercent(value: number): void
setMaxWidth(value: number): void
setMaxWidthPercent(value: number): void
setMaxHeight(value: number): void
setMaxHeightPercent(value: number): void
setFlexDirection(dir: LayoutFlexDirection): void
setFlexGrow(value: number): void
setFlexShrink(value: number): void
setFlexBasis(value: number): void
setFlexBasisPercent(value: number): void
setFlexWrap(wrap: LayoutWrap): void
setAlignItems(align: LayoutAlign): void
setAlignSelf(align: LayoutAlign): void
setJustifyContent(justify: LayoutJustify): void
setDisplay(display: LayoutDisplay): void
getDisplay(): LayoutDisplay
setPositionType(type: LayoutPositionType): void
setPosition(edge: LayoutEdge, value: number): void
setPositionPercent(edge: LayoutEdge, value: number): void
setOverflow(overflow: LayoutOverflow): void
setMargin(edge: LayoutEdge, value: number): void
setPadding(edge: LayoutEdge, value: number): void
setBorder(edge: LayoutEdge, value: number): void
setGap(gutter: LayoutGutter, value: number): void
// 生命周期
free(): void
freeRecursive(): void
}

View File

@@ -0,0 +1,152 @@
// --
// 布局引擎Yoga的适配器接口
export const LayoutEdge = {
All: 'all',
Horizontal: 'horizontal',
Vertical: 'vertical',
Left: 'left',
Right: 'right',
Top: 'top',
Bottom: 'bottom',
Start: 'start',
End: 'end',
} as const
export type LayoutEdge = (typeof LayoutEdge)[keyof typeof LayoutEdge]
export const LayoutGutter = {
All: 'all',
Column: 'column',
Row: 'row',
} as const
export type LayoutGutter = (typeof LayoutGutter)[keyof typeof LayoutGutter]
export const LayoutDisplay = {
Flex: 'flex',
None: 'none',
} as const
export type LayoutDisplay = (typeof LayoutDisplay)[keyof typeof LayoutDisplay]
export const LayoutFlexDirection = {
Row: 'row',
RowReverse: 'row-reverse',
Column: 'column',
ColumnReverse: 'column-reverse',
} as const
export type LayoutFlexDirection =
(typeof LayoutFlexDirection)[keyof typeof LayoutFlexDirection]
export const LayoutAlign = {
Auto: 'auto',
Stretch: 'stretch',
FlexStart: 'flex-start',
Center: 'center',
FlexEnd: 'flex-end',
} as const
export type LayoutAlign = (typeof LayoutAlign)[keyof typeof LayoutAlign]
export const LayoutJustify = {
FlexStart: 'flex-start',
Center: 'center',
FlexEnd: 'flex-end',
SpaceBetween: 'space-between',
SpaceAround: 'space-around',
SpaceEvenly: 'space-evenly',
} as const
export type LayoutJustify = (typeof LayoutJustify)[keyof typeof LayoutJustify]
export const LayoutWrap = {
NoWrap: 'nowrap',
Wrap: 'wrap',
WrapReverse: 'wrap-reverse',
} as const
export type LayoutWrap = (typeof LayoutWrap)[keyof typeof LayoutWrap]
export const LayoutPositionType = {
Relative: 'relative',
Absolute: 'absolute',
} as const
export type LayoutPositionType =
(typeof LayoutPositionType)[keyof typeof LayoutPositionType]
export const LayoutOverflow = {
Visible: 'visible',
Hidden: 'hidden',
Scroll: 'scroll',
} as const
export type LayoutOverflow =
(typeof LayoutOverflow)[keyof typeof LayoutOverflow]
export type LayoutMeasureFunc = (
width: number,
widthMode: LayoutMeasureMode,
) => { width: number; height: number }
export const LayoutMeasureMode = {
Undefined: 'undefined',
Exactly: 'exactly',
AtMost: 'at-most',
} as const
export type LayoutMeasureMode =
(typeof LayoutMeasureMode)[keyof typeof LayoutMeasureMode]
export type LayoutNode = {
// 树
insertChild(child: LayoutNode, index: number): void
removeChild(child: LayoutNode): void
getChildCount(): number
getParent(): LayoutNode | null
// 布局计算
calculateLayout(width?: number, height?: number): void
setMeasureFunc(fn: LayoutMeasureFunc): void
unsetMeasureFunc(): void
markDirty(): void
// 布局读取(布局后)
getComputedLeft(): number
getComputedTop(): number
getComputedWidth(): number
getComputedHeight(): number
getComputedBorder(edge: LayoutEdge): number
getComputedPadding(edge: LayoutEdge): number
// 样式设置器
setWidth(value: number): void
setWidthPercent(value: number): void
setWidthAuto(): void
setHeight(value: number): void
setHeightPercent(value: number): void
setHeightAuto(): void
setMinWidth(value: number): void
setMinWidthPercent(value: number): void
setMinHeight(value: number): void
setMinHeightPercent(value: number): void
setMaxWidth(value: number): void
setMaxWidthPercent(value: number): void
setMaxHeight(value: number): void
setMaxHeightPercent(value: number): void
setFlexDirection(dir: LayoutFlexDirection): void
setFlexGrow(value: number): void
setFlexShrink(value: number): void
setFlexBasis(value: number): void
setFlexBasisPercent(value: number): void
setFlexWrap(wrap: LayoutWrap): void
setAlignItems(align: LayoutAlign): void
setAlignSelf(align: LayoutAlign): void
setJustifyContent(justify: LayoutJustify): void
setDisplay(display: LayoutDisplay): void
getDisplay(): LayoutDisplay
setPositionType(type: LayoutPositionType): void
setPosition(edge: LayoutEdge, value: number): void
setPositionPercent(edge: LayoutEdge, value: number): void
setOverflow(overflow: LayoutOverflow): void
setMargin(edge: LayoutEdge, value: number): void
setPadding(edge: LayoutEdge, value: number): void
setBorder(edge: LayoutEdge, value: number): void
setGap(gutter: LayoutGutter, value: number): void
// 生命周期
free(): void
freeRecursive(): void
}

View File

@@ -0,0 +1,315 @@
import Yoga, {
Align,
Direction,
Display,
Edge,
FlexDirection,
Gutter,
Justify,
MeasureMode,
Overflow,
PositionType,
Wrap,
type Node as YogaNode,
} from 'src/native-ts/yoga-layout/index.js'
import {
type LayoutAlign,
LayoutDisplay,
type LayoutEdge,
type LayoutFlexDirection,
type LayoutGutter,
type LayoutJustify,
type LayoutMeasureFunc,
LayoutMeasureMode,
type LayoutNode,
type LayoutOverflow,
type LayoutPositionType,
type LayoutWrap,
} from './node.js'
// --
// 边缘/间距映射
const EDGE_MAP: Record<LayoutEdge, Edge> = {
all: Edge.All,
horizontal: Edge.Horizontal,
vertical: Edge.Vertical,
left: Edge.Left,
right: Edge.Right,
top: Edge.Top,
bottom: Edge.Bottom,
start: Edge.Start,
end: Edge.End,
}
const GUTTER_MAP: Record<LayoutGutter, Gutter> = {
all: Gutter.All,
column: Gutter.Column,
row: Gutter.Row,
}
// --
// Yoga 适配器
/**
* YogaLayoutNode - Yoga 布局引擎的 TypeScript 适配器
* 封装原生 Yoga C++ 节点,提供类型安全的接口
*/
export class YogaLayoutNode implements LayoutNode {
readonly yoga: YogaNode
constructor(yoga: YogaNode) {
this.yoga = yoga
}
// 树操作
insertChild(child: LayoutNode, index: number): void {
this.yoga.insertChild((child as YogaLayoutNode).yoga, index)
}
removeChild(child: LayoutNode): void {
this.yoga.removeChild((child as YogaLayoutNode).yoga)
}
getChildCount(): number {
return this.yoga.getChildCount()
}
getParent(): LayoutNode | null {
const p = this.yoga.getParent()
return p ? new YogaLayoutNode(p) : null
}
// 布局
calculateLayout(width?: number, _height?: number): void {
this.yoga.calculateLayout(width, undefined, Direction.LTR)
}
setMeasureFunc(fn: LayoutMeasureFunc): void {
this.yoga.setMeasureFunc((w, wMode) => {
const mode =
wMode === MeasureMode.Exactly
? LayoutMeasureMode.Exactly
: wMode === MeasureMode.AtMost
? LayoutMeasureMode.AtMost
: LayoutMeasureMode.Undefined
return fn(w, mode)
})
}
unsetMeasureFunc(): void {
this.yoga.unsetMeasureFunc()
}
markDirty(): void {
this.yoga.markDirty()
}
// 计算后的布局
getComputedLeft(): number {
return this.yoga.getComputedLeft()
}
getComputedTop(): number {
return this.yoga.getComputedTop()
}
getComputedWidth(): number {
return this.yoga.getComputedWidth()
}
getComputedHeight(): number {
return this.yoga.getComputedHeight()
}
getComputedBorder(edge: LayoutEdge): number {
return this.yoga.getComputedBorder(EDGE_MAP[edge]!)
}
getComputedPadding(edge: LayoutEdge): number {
return this.yoga.getComputedPadding(EDGE_MAP[edge]!)
}
// 样式设置器
setWidth(value: number): void {
this.yoga.setWidth(value)
}
setWidthPercent(value: number): void {
this.yoga.setWidthPercent(value)
}
setWidthAuto(): void {
this.yoga.setWidthAuto()
}
setHeight(value: number): void {
this.yoga.setHeight(value)
}
setHeightPercent(value: number): void {
this.yoga.setHeightPercent(value)
}
setHeightAuto(): void {
this.yoga.setHeightAuto()
}
setMinWidth(value: number): void {
this.yoga.setMinWidth(value)
}
setMinWidthPercent(value: number): void {
this.yoga.setMinWidthPercent(value)
}
setMinHeight(value: number): void {
this.yoga.setMinHeight(value)
}
setMinHeightPercent(value: number): void {
this.yoga.setMinHeightPercent(value)
}
setMaxWidth(value: number): void {
this.yoga.setMaxWidth(value)
}
setMaxWidthPercent(value: number): void {
this.yoga.setMaxWidthPercent(value)
}
setMaxHeight(value: number): void {
this.yoga.setMaxHeight(value)
}
setMaxHeightPercent(value: number): void {
this.yoga.setMaxHeightPercent(value)
}
setFlexDirection(dir: LayoutFlexDirection): void {
const map: Record<LayoutFlexDirection, FlexDirection> = {
row: FlexDirection.Row,
'row-reverse': FlexDirection.RowReverse,
column: FlexDirection.Column,
'column-reverse': FlexDirection.ColumnReverse,
}
this.yoga.setFlexDirection(map[dir]!)
}
setFlexGrow(value: number): void {
this.yoga.setFlexGrow(value)
}
setFlexShrink(value: number): void {
this.yoga.setFlexShrink(value)
}
setFlexBasis(value: number): void {
this.yoga.setFlexBasis(value)
}
setFlexBasisPercent(value: number): void {
this.yoga.setFlexBasisPercent(value)
}
setFlexWrap(wrap: LayoutWrap): void {
const map: Record<LayoutWrap, Wrap> = {
nowrap: Wrap.NoWrap,
wrap: Wrap.Wrap,
'wrap-reverse': Wrap.WrapReverse,
}
this.yoga.setFlexWrap(map[wrap]!)
}
setAlignItems(align: LayoutAlign): void {
const map: Record<LayoutAlign, Align> = {
auto: Align.Auto,
stretch: Align.Stretch,
'flex-start': Align.FlexStart,
center: Align.Center,
'flex-end': Align.FlexEnd,
}
this.yoga.setAlignItems(map[align]!)
}
setAlignSelf(align: LayoutAlign): void {
const map: Record<LayoutAlign, Align> = {
auto: Align.Auto,
stretch: Align.Stretch,
'flex-start': Align.FlexStart,
center: Align.Center,
'flex-end': Align.FlexEnd,
}
this.yoga.setAlignSelf(map[align]!)
}
setJustifyContent(justify: LayoutJustify): void {
const map: Record<LayoutJustify, Justify> = {
'flex-start': Justify.FlexStart,
center: Justify.Center,
'flex-end': Justify.FlexEnd,
'space-between': Justify.SpaceBetween,
'space-around': Justify.SpaceAround,
'space-evenly': Justify.SpaceEvenly,
}
this.yoga.setJustifyContent(map[justify]!)
}
setDisplay(display: LayoutDisplay): void {
this.yoga.setDisplay(display === 'flex' ? Display.Flex : Display.None)
}
getDisplay(): LayoutDisplay {
return this.yoga.getDisplay() === Display.None
? LayoutDisplay.None
: LayoutDisplay.Flex
}
setPositionType(type: LayoutPositionType): void {
this.yoga.setPositionType(
type === 'absolute' ? PositionType.Absolute : PositionType.Relative,
)
}
setPosition(edge: LayoutEdge, value: number): void {
this.yoga.setPosition(EDGE_MAP[edge]!, value)
}
setPositionPercent(edge: LayoutEdge, value: number): void {
this.yoga.setPositionPercent(EDGE_MAP[edge]!, value)
}
setOverflow(overflow: LayoutOverflow): void {
const map: Record<LayoutOverflow, Overflow> = {
visible: Overflow.Visible,
hidden: Overflow.Hidden,
scroll: Overflow.Scroll,
}
this.yoga.setOverflow(map[overflow]!)
}
setMargin(edge: LayoutEdge, value: number): void {
this.yoga.setMargin(EDGE_MAP[edge]!, value)
}
setPadding(edge: LayoutEdge, value: number): void {
this.yoga.setPadding(EDGE_MAP[edge]!, value)
}
setBorder(edge: LayoutEdge, value: number): void {
this.yoga.setBorder(EDGE_MAP[edge]!, value)
}
setGap(gutter: LayoutGutter, value: number): void {
this.yoga.setGap(GUTTER_MAP[gutter]!, value)
}
// 生命周期
free(): void {
this.yoga.free()
}
freeRecursive(): void {
this.yoga.freeRecursive()
}
}
// --
// 实例管理
//
// TypeScript yoga-layout 移植是同步的——无 WASM 加载,无线性内存
// 增长,因此无需预加载/交换/重置机制。Yoga 实例是
// 在导入时可用的普通 JS 对象。
/**
* 创建 Yoga 布局节点。
*/
export function createYogaLayoutNode(): LayoutNode {
return new YogaLayoutNode(Yoga.Node.create())
}

View File

@@ -0,0 +1,24 @@
import { stringWidth } from './stringWidth.js'
// 在流式传输期间,文本增长但完成的行是不可变的。
// 每行缓存 stringWidth 避免在每个标记上重新测量数百行
// 未更改的行stringWidth 调用减少约 50 倍)。
const cache = new Map<string, number>()
const MAX_CACHE_SIZE = 4096
export function lineWidth(line: string): number {
const cached = cache.get(line)
if (cached !== undefined) return cached
const width = stringWidth(line)
// 当缓存增长过大时驱逐(例如在许多不同响应之后)。
// 简单的全清除就好 — 缓存在一帧中重新填充。
if (cache.size >= MAX_CACHE_SIZE) {
cache.clear()
}
cache.set(line, width)
return width
}

View File

@@ -0,0 +1,762 @@
import {
type AnsiCode,
ansiCodesToString,
diffAnsiCodes,
} from '@alcalzone/ansi-tokenize'
import { logForDebugging } from '../utils/debug.js'
import type { Diff, FlickerReason, Frame } from './frame.js'
import type { Point } from './layout/geometry.js'
import {
type Cell,
CellWidth,
cellAt,
charInCellAt,
diffEach,
type Hyperlink,
isEmptyCellAt,
type Screen,
type StylePool,
shiftRows,
visibleCellAtIndex,
} from './screen.js'
import {
CURSOR_HOME,
scrollDown as csiScrollDown,
scrollUp as csiScrollUp,
RESET_SCROLL_REGION,
setScrollRegion,
} from './termio/csi.js'
import { LINK_END, link as oscLink } from './termio/osc.js'
type State = {
previousOutput: string
}
type Options = {
isTTY: boolean
stylePool: StylePool
}
const CARRIAGE_RETURN = { type: 'carriageReturn' } as const
const NEWLINE = { type: 'stdout', content: '\n' } as const
export class LogUpdate {
private state: State
constructor(private readonly options: Options) {
this.state = {
previousOutput: '',
}
}
renderPreviousOutput_DEPRECATED(prevFrame: Frame): Diff {
if (!this.options.isTTY) {
// 非 TTY 输出不再支持(字符串输出已移除)
return [NEWLINE]
}
return this.getRenderOpsForDone(prevFrame)
}
// 在进程从挂起恢复SIGCONT时调用以防止弄乱终端内容
reset(): void {
this.state.previousOutput = ''
}
private renderFullFrame(frame: Frame): Diff {
const { screen } = frame
const lines: string[] = []
let currentStyles: AnsiCode[] = []
let currentHyperlink: Hyperlink = undefined
for (let y = 0; y < screen.height; y++) {
let line = ''
for (let x = 0; x < screen.width; x++) {
const cell = cellAt(screen, x, y)
if (cell && cell.width !== CellWidth.SpacerTail) {
// 处理超链接转换
if (cell.hyperlink !== currentHyperlink) {
if (currentHyperlink !== undefined) {
line += LINK_END
}
if (cell.hyperlink !== undefined) {
line += oscLink(cell.hyperlink)
}
currentHyperlink = cell.hyperlink
}
const cellStyles = this.options.stylePool.get(cell.styleId)
const styleDiff = diffAnsiCodes(currentStyles, cellStyles)
if (styleDiff.length > 0) {
line += ansiCodesToString(styleDiff)
currentStyles = cellStyles
}
line += cell.char
}
}
// 在重置样式之前关闭任何打开的超链接
if (currentHyperlink !== undefined) {
line += LINK_END
currentHyperlink = undefined
}
// 在行尾重置样式,这样 trimEnd 就不会留下悬空的代码
const resetCodes = diffAnsiCodes(currentStyles, [])
if (resetCodes.length > 0) {
line += ansiCodesToString(resetCodes)
currentStyles = []
}
lines.push(line.trimEnd())
}
if (lines.length === 0) {
return []
}
return [{ type: 'stdout', content: lines.join('\n') }]
}
private getRenderOpsForDone(prev: Frame): Diff {
this.state.previousOutput = ''
if (!prev.cursor.visible) {
return [{ type: 'cursorShow' }]
}
return []
}
render(
prev: Frame,
next: Frame,
altScreen = false,
decstbmSafe = true,
): Diff {
if (!this.options.isTTY) {
return this.renderFullFrame(next)
}
const startTime = performance.now()
const stylePool = this.options.stylePool
// 因为我们假设光标在屏幕底部,我们只需要在视口变短时
//(即光标位置漂移)或变窄时(文本换行)清除。我们可以计算出
// 如何不在这里重置,但那需要预测视口变化后的当前布局
// 这意味着要计算文本换行。调整大小是足够罕见的事件,
// 实际上不是什么大问题。
if (
next.viewport.height < prev.viewport.height ||
(prev.viewport.width !== 0 && next.viewport.width !== prev.viewport.width)
) {
return fullResetSequence_CAUSES_FLICKER(next, 'resize', stylePool)
}
// DECSTBM 滚动优化:当 ScrollBox 的 scrollTop 改变时,
// 使用硬件滚动CSI top;bot r + CSI n S/T移动内容
// 而不是重写整个滚动区域。prev.screen 上的 shiftRows
// 模拟滚动,因此下面的 diff 循环自然地找到只有滚入的
// 行作为 diff。prev.screen 即将成为 backFrame下次渲染重用
// 所以改变是安全的。RESET_SCROLL_REGION 后的 CURSOR_HOME 是防御性的
// — DECSTBM 重置按规范将光标归位,但终端实现各不相同。
//
// decstbmSafe当 DECSTBM→diff 序列不能原子化时,调用者传递 false
//(没有 DEC 2026 / BSU/ESU。没有原子性外部终端渲染中间状态
// — 区域已滚动,边缘行尚未绘制 — 在 scrollTop 移动的每帧上
// 出现可见的垂直跳跃。落入 diff 循环写入所有滚动的行:
// 更多字节无中间状态。无论哪种方式render-node-to-output 的
// blit+shift 的 next.screen 都是正确的。
let scrollPatch: Diff = []
if (altScreen && next.scrollHint && decstbmSafe) {
const { top, bottom, delta } = next.scrollHint
if (
top >= 0 &&
bottom < prev.screen.height &&
bottom < next.screen.height
) {
shiftRows(prev.screen, top, bottom, delta)
scrollPatch = [
{
type: 'stdout',
content:
setScrollRegion(top + 1, bottom + 1) +
(delta > 0 ? csiScrollUp(delta) : csiScrollDown(-delta)) +
RESET_SCROLL_REGION +
CURSOR_HOME,
},
]
}
}
// 我们必须使用纯相对操作来操作光标,因为我们不知道它的起始点。
//
// 当内容高度 >= 视口高度且光标在底部时,
// 上一帧结束时的光标恢复导致终端滚动。
// viewportY 告诉我们内容溢出时滚动缓冲区中有多少行。
// 此外,光标恢复滚动将 1 行推入滚动缓冲区。
// 如果任何更改在现在位于滚动缓冲区中的行上,我们需要 fullReset。
//
// 这个提前 full-reset 检查仅适用于"稳定状态"(不是增长)。
// 对于增长,下面的 viewportY 计算(带有 cursorRestoreScroll
// 在 diff 循环中捕获不可达的滚动缓冲区行。
const cursorAtBottom = prev.cursor.y >= prev.screen.height
const isGrowing = next.screen.height > prev.screen.height
// 当内容完全填充视口height == viewport且光标在底部时
// 上一帧结束时的光标恢复 LF 将 1 行滚动到滚动缓冲区。
// 使用 >= 来捕获这个。
const prevHadScrollback =
cursorAtBottom && prev.screen.height >= prev.viewport.height
const isShrinking = next.screen.height < prev.screen.height
const nextFitsViewport = next.screen.height <= prev.viewport.height
// 当从视口上方收缩到视口或下方时,原来在滚动缓冲区中的内容现在应该可见。
// 终端清除操作无法将滚动缓冲区内容带入视图,所以我们需要完全重置。
// 使用 <=(而不是 <),因为即使下一高度等于视口高度,
// 前一次渲染的滚动缓冲区深度与全新渲染不同。
if (prevHadScrollback && nextFitsViewport && isShrinking) {
logForDebugging(
`Full reset (shrink->below): prevHeight=${prev.screen.height}, nextHeight=${next.screen.height}, viewport=${prev.viewport.height}`,
)
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool)
}
if (
prev.screen.height >= prev.viewport.height &&
prev.screen.height > 0 &&
cursorAtBottom &&
!isGrowing
) {
// viewportY = 内容溢出时滚动缓冲区中的行数
// +1 用于光标恢复滚动推送的行
const viewportY = prev.screen.height - prev.viewport.height
const scrollbackRows = viewportY + 1
let scrollbackChangeY = -1
diffEach(prev.screen, next.screen, (_x, y) => {
if (y < scrollbackRows) {
scrollbackChangeY = y
return true // 提前退出
}
})
if (scrollbackChangeY >= 0) {
const prevLine = readLine(prev.screen, scrollbackChangeY)
const nextLine = readLine(next.screen, scrollbackChangeY)
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, {
triggerY: scrollbackChangeY,
prevLine,
nextLine,
})
}
}
const screen = new VirtualScreen(prev.cursor, next.viewport.width)
// 将空屏幕视为高度 1 以避免首次渲染时出现虚假调整
const heightDelta =
Math.max(next.screen.height, 1) - Math.max(prev.screen.height, 1)
const shrinking = heightDelta < 0
const growing = heightDelta > 0
// 处理收缩:从底部清除行
if (shrinking) {
const linesToClear = prev.screen.height - next.screen.height
// eraseLines 仅在视口内工作 — 它无法清除滚动缓冲区。
// 如果我们需要清除的行数超过视口能容纳的行数,有些就在
// 滚动缓冲区中,所以我们需要完全重置。
if (linesToClear > prev.viewport.height) {
return fullResetSequence_CAUSES_FLICKER(
next,
'offscreen',
this.options.stylePool,
)
}
// clear(N) 向上移动 N-1 行到列 0
// 这将我们放在行 prev.screen.height - N = next.screen.height
// 但我们想要在 next.screen.height - 1新屏幕底部
screen.txn(prev => [
[
{ type: 'clear', count: linesToClear },
{ type: 'cursorMove', x: 0, y: -1 },
],
{ dx: -prev.x, dy: -linesToClear },
])
}
// viewportY = 滚动缓冲区中的行数(终端上不可见)。
// 对于收缩:使用 max(prev, next) 因为终端清除不滚动。
// 对于增长:使用 prev 状态因为新行尚未滚动旧行。
// 当 prevHadScrollback 时,为上一帧结束时滚动出视图的
// 光标恢复 LF 添加 1。没有这个diff 循环将那一行视为可达
// — 但光标在视口顶部夹紧,导致写入位置偏离 1 行并使输出乱码。
const cursorRestoreScroll = prevHadScrollback ? 1 : 0
const viewportY = growing
? Math.max(
0,
prev.screen.height - prev.viewport.height + cursorRestoreScroll,
)
: Math.max(prev.screen.height, next.screen.height) -
next.viewport.height +
cursorRestoreScroll
let currentStyleId = stylePool.none
let currentHyperlink: Hyperlink = undefined
// 第一遍:渲染现有行的更改(行 < prev.screen.height
let needsFullReset = false
let resetTriggerY = -1
diffEach(prev.screen, next.screen, (x, y, removed, added) => {
// 跳过新行 — 我们之后直接渲染它们
if (growing && y >= prev.screen.height) {
return
}
// 在渲染期间跳过 spacer 因为终端在我们写入
// 宽字符本身时会自动前进 2 列。
// SpacerTail宽字符的第二个单元格
// SpacerHead标记宽字符换行到下一行的行尾位置
if (
added &&
(added.width === CellWidth.SpacerTail ||
added.width === CellWidth.SpacerHead)
) {
return
}
if (
removed &&
(removed.width === CellWidth.SpacerTail ||
removed.width === CellWidth.SpacerHead) &&
!added
) {
return
}
// 跳过不需要覆盖现有内容的空单元格。
// 这防止写入会在线边缘引起不必要换行的尾随空格。
// 使用 isEmptyCellAt 检查两个填充词是否都为零(空单元格)。
if (added && isEmptyCellAt(next.screen, x, y) && !removed) {
return
}
// 如果视口范围之外的单元格已更改,我们需要重置,
// 因为我们无法将光标移动到那里绘制。
if (y < viewportY) {
needsFullReset = true
resetTriggerY = y
return true // 提前退出
}
moveCursorTo(screen, x, y)
if (added) {
const targetHyperlink = added.hyperlink
currentHyperlink = transitionHyperlink(
screen.diff,
currentHyperlink,
targetHyperlink,
)
const styleStr = stylePool.transition(currentStyleId, added.styleId)
if (writeCellWithStyleStr(screen, added, styleStr)) {
currentStyleId = added.styleId
}
} else if (removed) {
// 单元格被移除 — 用空格清除它
//(这处理收缩内容)
// 首先重置任何活动的样式/超链接以避免泄漏到清除的单元格中
const styleIdToReset = currentStyleId
const hyperlinkToReset = currentHyperlink
currentStyleId = stylePool.none
currentHyperlink = undefined
screen.txn(() => {
const patches: Diff = []
transitionStyle(patches, stylePool, styleIdToReset, stylePool.none)
transitionHyperlink(patches, hyperlinkToReset, undefined)
patches.push({ type: 'stdout', content: ' ' })
return [patches, { dx: 1, dy: 0 }]
})
}
})
if (needsFullReset) {
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, {
triggerY: resetTriggerY,
prevLine: readLine(prev.screen, resetTriggerY),
nextLine: readLine(next.screen, resetTriggerY),
})
}
// 在渲染新行之前重置样式(它们会设置自己的样式)
currentStyleId = transitionStyle(
screen.diff,
stylePool,
currentStyleId,
stylePool.none,
)
currentHyperlink = transitionHyperlink(
screen.diff,
currentHyperlink,
undefined,
)
// 处理增长:直接渲染新行(它们自然滚动终端)
if (growing) {
renderFrameSlice(
screen,
next,
prev.screen.height,
next.screen.height,
stylePool,
)
}
// 恢复光标。在 alt-screen 中跳过:光标是隐藏的,
// 它的位置仅作为下一帧相对移动的起始点重要,
// 在 alt-screen 中下一帧总是以 CSI H 开始(见 ink.tsx onRender
// 无论光标在哪里都会重置到 (0,0)。这节省了每帧的
// CR + cursorMove 往返(~6-10 字节)。
//
// 主屏幕:如果光标需要超过内容的最后一行
//典型cursor.y = screen.height发出 \n 来创建那一行,
// 因为光标移动无法创建新行。
if (altScreen) {
// 空操作;下一帧的 CSI H 锚定光标
} else if (next.cursor.y >= next.screen.height) {
// 移动到当前行的列 0然后发出换行符到达目标行
screen.txn(prev => {
const rowsToCreate = next.cursor.y - prev.y
if (rowsToCreate > 0) {
// 使用 CR 解决待处理的换行(如果有)而不前进到下一行,
// 然后用 LF 创建每一行新行。
const patches: Diff = new Array<Diff[number]>(1 + rowsToCreate)
patches[0] = CARRIAGE_RETURN
for (let i = 0; i < rowsToCreate; i++) {
patches[1 + i] = NEWLINE
}
return [patches, { dx: -prev.x, dy: rowsToCreate }]
}
// 在或超过目标行 — 需要将光标移动到正确位置
const dy = next.cursor.y - prev.y
if (dy !== 0 || prev.x !== next.cursor.x) {
// 使用 CR 清除待处理的换行(如果有),然后移动光标
const patches: Diff = [CARRIAGE_RETURN]
patches.push({ type: 'cursorMove', x: next.cursor.x, y: dy })
return [patches, { dx: next.cursor.x - prev.x, dy }]
}
return [[], { dx: 0, dy: 0 }]
})
} else {
moveCursorTo(screen, next.cursor.x, next.cursor.y)
}
const elapsed = performance.now() - startTime
if (elapsed > 50) {
const damage = next.screen.damage
const damageInfo = damage
? `${damage.width}x${damage.height} at (${damage.x},${damage.y})`
: 'none'
logForDebugging(
`Slow render: ${elapsed.toFixed(1)}ms, screen: ${next.screen.height}x${next.screen.width}, damage: ${damageInfo}, changes: ${screen.diff.length}`,
)
}
return scrollPatch.length > 0
? [...scrollPatch, ...screen.diff]
: screen.diff
}
}
function transitionHyperlink(
diff: Diff,
current: Hyperlink,
target: Hyperlink,
): Hyperlink {
if (current !== target) {
diff.push({ type: 'hyperlink', uri: target ?? '' })
return target
}
return current
}
function transitionStyle(
diff: Diff,
stylePool: StylePool,
currentId: number,
targetId: number,
): number {
const str = stylePool.transition(currentId, targetId)
if (str.length > 0) {
diff.push({ type: 'styleStr', str })
}
return targetId
}
function readLine(screen: Screen, y: number): string {
let line = ''
for (let x = 0; x < screen.width; x++) {
line += charInCellAt(screen, x, y) ?? ' '
}
return line.trimEnd()
}
function fullResetSequence_CAUSES_FLICKER(
frame: Frame,
reason: FlickerReason,
stylePool: StylePool,
debug?: { triggerY: number; prevLine: string; nextLine: string },
): Diff {
// clearTerminal 后,光标在 (0, 0)
const screen = new VirtualScreen({ x: 0, y: 0 }, frame.viewport.width)
renderFrame(screen, frame, stylePool)
return [{ type: 'clearTerminal', reason, debug }, ...screen.diff]
}
function renderFrame(
screen: VirtualScreen,
frame: Frame,
stylePool: StylePool,
): void {
renderFrameSlice(screen, frame, 0, frame.screen.height, stylePool)
}
/**
* 渲染帧屏幕的行切片。
* 每一行渲染后跟一个换行符。光标结束在 (0, endY)。
*/
function renderFrameSlice(
screen: VirtualScreen,
frame: Frame,
startY: number,
endY: number,
stylePool: StylePool,
): VirtualScreen {
let currentStyleId = stylePool.none
let currentHyperlink: Hyperlink = undefined
// 跟踪此行上最后渲染单元格的 styleId如果没有则为 -1
// 传递给 visibleCellAtIndex 以启用仅 fg 的空格优化。
let lastRenderedStyleId = -1
const { width: screenWidth, cells, charPool, hyperlinkPool } = frame.screen
let index = startY * screenWidth
for (let y = startY; y < endY; y += 1) {
// 使用 LF不是 CSI CUD / cursor-down将光标前进到此行。
// CSI CUD 停在视口底部边距,无法滚动,
// 但 LF 滚动视口创建新行。没有这个,
// 当光标在视口底部时moveCursorTo 的
// cursor-down 静默失败,在虚拟光标和真实终端光标之间产生永久的 off-by-one。
if (screen.cursor.y < y) {
const rowsToAdvance = y - screen.cursor.y
screen.txn(prev => {
const patches: Diff = new Array<Diff[number]>(1 + rowsToAdvance)
patches[0] = CARRIAGE_RETURN
for (let i = 0; i < rowsToAdvance; i++) {
patches[1 + i] = NEWLINE
}
return [patches, { dx: -prev.x, dy: rowsToAdvance }]
})
}
// 在每行开始时重置 — 尚未渲染任何单元格
lastRenderedStyleId = -1
for (let x = 0; x < screenWidth; x += 1, index += 1) {
// 跳过 spacer、无样式空单元格和与最后渲染样式匹配的
// 仅 fg 样式空格(因为 cursor-forward 产生相同的视觉结果)。
// visibleCellAtIndex 内部处理优化以避免为跳过的单元格分配 Cell 对象。
const cell = visibleCellAtIndex(
cells,
charPool,
hyperlinkPool,
index,
lastRenderedStyleId,
)
if (!cell) {
continue
}
moveCursorTo(screen, x, y)
// 处理超链接
const targetHyperlink = cell.hyperlink
currentHyperlink = transitionHyperlink(
screen.diff,
currentHyperlink,
targetHyperlink,
)
// 样式转换 — 缓存字符串,热身后零分配
const styleStr = stylePool.transition(currentStyleId, cell.styleId)
if (writeCellWithStyleStr(screen, cell, styleStr)) {
currentStyleId = cell.styleId
lastRenderedStyleId = cell.styleId
}
}
// 在换行符之前重置样式/超链接,这样背景颜色在终端滚动时不会
// 渗入下一行。旧代码通过写入尾随无样式空格隐式重置;
// 现在我们跳过空单元格,必须显式重置。
currentStyleId = transitionStyle(
screen.diff,
stylePool,
currentStyleId,
stylePool.none,
)
currentHyperlink = transitionHyperlink(
screen.diff,
currentHyperlink,
undefined,
)
// 行尾的 CR+LF — \r 重置到列 0\n 移动到下一行。
// 没有 \r终端光标停留在内容结束的任何列
//(因为我们跳过尾随空格,这可能是中行)。
screen.txn(prev => [[CARRIAGE_RETURN, NEWLINE], { dx: -prev.x, dy: 1 }])
}
// 在切片结束时重置任何打开的样式/超链接
transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none)
transitionHyperlink(screen.diff, currentHyperlink, undefined)
return screen
}
type Delta = { dx: number; dy: number }
/**
* 使用预序列化的样式转换字符串写入单元格(来自
* StylePool.transition。内联 txn 逻辑以避免每个单元格的闭包/元组/delta 分配。
*
* 如果单元格被写入则返回 true如果被跳过视口边缘的宽字符则返回 false。
* 调用者必须在返回值上门控 currentStyleId 更新 — 当
* 跳过时styleStr 从不被推送,终端的样式状态不变。
* 无论如何更新虚拟跟踪器都会将其与终端不同步,
* 下一个转换从 phantom 状态计算。
*/
function writeCellWithStyleStr(
screen: VirtualScreen,
cell: Cell,
styleStr: string,
): boolean {
const cellWidth = cell.width === CellWidth.Wide ? 2 : 1
const px = screen.cursor.x
const vw = screen.viewportWidth
// 不写会跨越视口边缘的宽字符。
// 单码点字符CJK在 vw-2 是安全的;多码点
// 字素旗帜、ZWJ 表情符号)需要更严格的阈值。
if (cellWidth === 2 && px < vw) {
const threshold = cell.char.length > 2 ? vw : vw + 1
if (px + 2 >= threshold) {
return false
}
}
const diff = screen.diff
if (styleStr.length > 0) {
diff.push({ type: 'styleStr', str: styleStr })
}
const needsCompensation = cellWidth === 2 && needsWidthCompensation(cell.char)
// 在具有旧 wcwidth 表的终端上,补偿的表情符号仅将
// 光标前进 1 列,所以下面的 CHA 跳过列 x+1 而不绘制它。
// 首先在那里写一个带样式的空格 — 在正确的终端上表情符号
// 字形(宽度 2无害地覆盖它在旧终端上它用表情符号
// 的背景填充间隙。也清除 x+1 处的任何陈旧内容。
// CHA 是 1-based所以列 px+10-based是 CHA 目标 px+2。
if (needsCompensation && px + 1 < vw) {
diff.push({ type: 'cursorTo', col: px + 2 })
diff.push({ type: 'stdout', content: ' ' })
diff.push({ type: 'cursorTo', col: px + 1 })
}
diff.push({ type: 'stdout', content: cell.char })
// 在表情符号之后将终端光标强制到正确的列。
if (needsCompensation) {
diff.push({ type: 'cursorTo', col: px + cellWidth + 1 })
}
// 更新光标 — 就地改变以避免 Point 分配
if (px >= vw) {
screen.cursor.x = cellWidth
screen.cursor.y++
} else {
screen.cursor.x = px + cellWidth
}
return true
}
function moveCursorTo(screen: VirtualScreen, targetX: number, targetY: number) {
screen.txn(prev => {
const dx = targetX - prev.x
const dy = targetY - prev.y
const inPendingWrap = prev.x >= screen.viewportWidth
// 如果我们处于待处理换行状态cursor.x >= width使用 CR
// 重置到当前行的列 0 而不是前进到下一行,
// 然后发出光标移动。
if (inPendingWrap) {
return [
[CARRIAGE_RETURN, { type: 'cursorMove', x: targetX, y: dy }],
{ dx, dy },
]
}
// 当移动到不同的行时,使用回车符(\r首先重置到
// 列 0然后光标移动。
if (dy !== 0) {
return [
[CARRIAGE_RETURN, { type: 'cursorMove', x: targetX, y: dy }],
{ dx, dy },
]
}
// 标准同行动标移动
return [[{ type: 'cursorMove', x: dx, y: dy }], { dx, dy }]
})
}
/**
* 识别终端的 wcwidth 可能与 Unicode 不一致的 emoji。
* 在具有正确表的终端上,我们发出的 CHA 是无害的空操作。
*
* 两类:
* 1. 终端 wcwidth 表中缺少的较新 emojiUnicode 12.0+)。
* 2. 文本默认 emoji + VS16U+FE0F基本码点在 wcwidth 中宽度为 1
* 但 VS16 触发 emoji 呈现使其宽度为 2。
* 示例:⚔️ (U+2694), ☠️ (U+2620), ❤️ (U+2764)。
*/
function needsWidthCompensation(char: string): boolean {
const cp = char.codePointAt(0)
if (cp === undefined) return false
// U+1FA70-U+1FAFF: 符号和象形文字扩展-AUnicode 12.0-15.0
// U+1FB00-U+1FBFF: 传统计算符号Unicode 13.0
if ((cp >= 0x1fa70 && cp <= 0x1faff) || (cp >= 0x1fb00 && cp <= 0x1fbff)) {
return true
}
// 带 VS16 的文本默认 emoji扫描多码点字素中的 U+FE0F。
// 单 BMP 字符(长度 1和没有 VS16 的代理对跳过此检查。
// VS16 (0xFE0F) 不能与代理对0xD800-0xDFFF冲突。
if (char.length >= 2) {
for (let i = 0; i < char.length; i++) {
if (char.charCodeAt(i) === 0xfe0f) return true
}
}
return false
}
class VirtualScreen {
// 公共以供 writeCellWithStyleStr 直接改变(避免 txn 开销)。
// 文件私有类 — 不在 log-update.ts 外部暴露。
cursor: Point
diff: Diff = []
constructor(
origin: Point,
readonly viewportWidth: number,
) {
this.cursor = { ...origin }
}
txn(fn: (prev: Point) => [patches: Diff, next: Delta]): void {
const [patches, next] = fn(this.cursor)
for (const patch of patches) {
this.diff.push(patch)
}
this.cursor.x += next.dx
this.cursor.y += next.dy
}
}

View File

@@ -0,0 +1,23 @@
import type { DOMElement } from './dom.js'
type Output = {
/**
* 元素宽度。
*/
width: number
/**
* 元素高度。
*/
height: number
}
/**
* 测量特定 `<Box>` 元素的尺寸。
*/
const measureElement = (node: DOMElement): Output => ({
width: node.yogaNode?.getComputedWidth() ?? 0,
height: node.yogaNode?.getComputedHeight() ?? 0,
})
export default measureElement

View File

@@ -0,0 +1,47 @@
import { lineWidth } from './line-width-cache.js'
type Output = {
width: number
height: number
}
// 单次传递测量:在一次迭代中计算宽度和高度,而不是两次
//widestLine + countVisualLines
// 使用 indexOf 避免从 split('\n') 分配数组。
function measureText(text: string, maxWidth: number): Output {
if (text.length === 0) {
return {
width: 0,
height: 0,
}
}
// 无限或非正值宽度意味着不换行 — 每行是一个视觉行。
// 因为 Math.ceil(w / Infinity) = 0必须在循环之前检查。
const noWrap = maxWidth <= 0 || !Number.isFinite(maxWidth)
let height = 0
let width = 0
let start = 0
while (start <= text.length) {
const end = text.indexOf('\n', start)
const line = end === -1 ? text.substring(start) : text.substring(start, end)
const w = lineWidth(line)
width = Math.max(width, w)
if (noWrap) {
height++
} else {
height += w === 0 ? 1 : Math.ceil(w / maxWidth)
}
if (end === -1) break
start = end + 1
}
return { width, height }
}
export default measureText

View File

@@ -0,0 +1,52 @@
import type { DOMElement } from './dom.js'
import type { Rectangle } from './layout/geometry.js'
/**
* 每个渲染节点的缓存布局边界(用于 blit + 清除)。
* `top` 是 yoga 本地的 getComputedTop() — 存储它以便 ScrollBox 视口
* 裁剪可以跳过位置未移动的干净子节点的 yoga 读取O(dirty) 而非 O(mounted) 第一遍)。
*/
export type CachedLayout = {
x: number
y: number
width: number
height: number
top?: number
}
export const nodeCache = new WeakMap<DOMElement, CachedLayout>()
/** 需要在下次渲染时清除的已移除子节点的矩形 */
export const pendingClears = new WeakMap<DOMElement, Rectangle[]>()
/**
* 当为绝对定位节点添加 pendingClear 时设置。
* 向渲染器发信号禁用下一帧的 blit已移除的节点
* 可能已在非兄弟节点上绘制(例如树顺序中较早的
* ScrollBox 上的覆盖),因此它们从 prevScreen 的 blits 会恢复
* 覆盖的像素。正常流移除已在父级通过 hasRemovedChild 处理;
* 只有绝对定位才会绘制跨子树。仅在每次渲染开始时重置。
*/
let absoluteNodeRemoved = false
export function addPendingClear(
parent: DOMElement,
rect: Rectangle,
isAbsolute: boolean,
): void {
const existing = pendingClears.get(parent)
if (existing) {
existing.push(rect)
} else {
pendingClears.set(parent, [rect])
}
if (isAbsolute) {
absoluteNodeRemoved = true
}
}
export function consumeAbsoluteRemovedFlag(): boolean {
const had = absoluteNodeRemoved
absoluteNodeRemoved = false
return had
}

View File

@@ -0,0 +1,93 @@
import type { Diff } from './frame.js'
/**
* 通过在单次传递中应用所有优化规则来优化 diff。
* 这减少了需要写入终端的补丁数量。
*
* 应用的规则:
* - 移除空 stdout 补丁
* - 合并连续的光标移动补丁
* - 移除无操作的光标移动 (0,0) 补丁
* - 连接相邻的样式补丁(转换 diff — 不能丢弃任何一个)
* - 对具有相同 URI 的连续超链接去重
* - 取消光标隐藏/显示配对
* - 移除计数为 0 的清除补丁
*/
export function optimize(diff: Diff): Diff {
if (diff.length <= 1) {
return diff
}
const result: Diff = []
let len = 0
for (const patch of diff) {
const type = patch.type
// 跳过无操作
if (type === 'stdout') {
if (patch.content === '') continue
} else if (type === 'cursorMove') {
if (patch.x === 0 && patch.y === 0) continue
} else if (type === 'clear') {
if (patch.count === 0) continue
}
// 尝试与前一个补丁合并
if (len > 0) {
const lastIdx = len - 1
const last = result[lastIdx]!
const lastType = last.type
// 合并连续的光标移动
if (type === 'cursorMove' && lastType === 'cursorMove') {
result[lastIdx] = {
type: 'cursorMove',
x: last.x + patch.x,
y: last.y + patch.y,
}
continue
}
// 折叠连续的光标位置(只有最后一个重要)
if (type === 'cursorTo' && lastType === 'cursorTo') {
result[lastIdx] = patch
continue
}
// 连接相邻的样式补丁。styleStr 是一个转换 diff
//(由 diffAnsiCodes(from, to) 计算),不是 setter — 丢弃
// 第一个只有在第二个的撤销代码是其子集时才合理,
// 这是不保证的。例如 [\e[49m, \e[2m]:丢弃
// bg 重置会通过 BCE 泄漏到下一个 \e[2J/\e[2K。
if (type === 'styleStr' && lastType === 'styleStr') {
result[lastIdx] = { type: 'styleStr', str: last.str + patch.str }
continue
}
// 对超链接去重
if (
type === 'hyperlink' &&
lastType === 'hyperlink' &&
patch.uri === last.uri
) {
continue
}
// 取消光标隐藏/显示配对
if (
(type === 'cursorShow' && lastType === 'cursorHide') ||
(type === 'cursorHide' && lastType === 'cursorShow')
) {
result.pop()
len--
continue
}
}
result.push(patch)
len++
}
return result
}

View File

@@ -0,0 +1,799 @@
/**
* 键盘输入解析器 - 将终端输入转换为按键事件
*
* 使用 termio 分词器进行转义序列边界检测,
* 然后将序列解释为按键。
*/
import { Buffer } from 'buffer'
import { PASTE_END, PASTE_START } from './termio/csi.js'
import { createTokenizer, type Tokenizer } from './termio/tokenize.js'
// eslint-disable-next-line no-control-regex
const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/
// eslint-disable-next-line no-control-regex
const FN_KEY_RE =
// eslint-disable-next-line no-control-regex
/^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/
// CSI ukitty 键盘协议ESC [ codepoint [; modifier] u
// 示例ESC[13;2u = Shift+EnterESC[27u = Escape无修饰符
// 修饰符是可选的 — 不存在时默认为 1无修饰符
// eslint-disable-next-line no-control-regex
const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/
// xterm modifyOtherKeysESC [ 27 ; modifier ; keycode ~
// 示例ESC[27;2;13~ = Shift+Enter。当 modifyOtherKeys=2 激活时或通过
// 用户按键绑定时由 Ghostty/tmux/xterm 发出,通常在 SSH 上,
// 因为 TERM 嗅探会错过 Ghostty我们永远不会推送 Kitty 键盘模式。
// 注意参数顺序与 CSI u 相反(修饰符在前,键码在后)。
// eslint-disable-next-line no-control-regex
const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/
// -- 终端响应模式(来自终端本身的入站序列)--
// DECRPMCSI ? Ps ; Pm $ y — DECRQM 的响应(请求模式)
// eslint-disable-next-line no-control-regex
const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/
// DA1CSI ? Ps ; ... c — 主设备属性响应
// eslint-disable-next-line no-control-regex
const DA1_RE = /^\x1b\[\?([\d;]*)c$/
// DA2CSI > Ps ; ... c — 次设备属性响应
// eslint-disable-next-line no-control-regex
const DA2_RE = /^\x1b\[>([\d;]*)c$/
// Kitty 键盘标志CSI ? flags u — CSI ? u 查询的响应
//(私有 ? 标记与 CSI u 按键事件区分)
// eslint-disable-next-line no-control-regex
const KITTY_FLAGS_RE = /^\x1b\[\?(\d+)u$/
// DECXCPR 光标位置CSI ? row ; col R
// ? 标记消歧修饰符 F3 键Shift+F3 = CSI 1;2 R
// Ctrl+F3 = CSI 1;5 R 等)— 纯 CSI row;col R 确实模糊。
// eslint-disable-next-line no-control-regex
const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/
// OSC 响应OSC code ; data (BEL|ST)
// eslint-disable-next-line no-control-regex
const OSC_RESPONSE_RE = /^\x1b\](\d+);(.*?)(?:\x07|\x1b\\)$/s
// XTVERSIONDCS > | name ST — 终端名称/版本字符串CSI > 0 q 的响应)。
// xterm.js 回复 "xterm.js(X.Y.Z)"Ghostty、kitty、iTerm2 等回复
// 自己的名称。与 TERM_PROGRAM 不同,这通过 pty 而不是环境进行 SSH
// 所以查询/响应会通过。
// eslint-disable-next-line no-control-regex
const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
// SGR 鼠标事件CSI < button ; col ; row M按下或 m释放
// 按钮代码64=滚轮向上65=滚轮向下0x40 | wheel-bit
// 按钮 32=左键拖动0x20 | motion-bit。纯 0/1/2 = 左/中/右键点击。
// eslint-disable-next-line no-control-regex
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
function createPasteKey(content: string): ParsedKey {
return {
kind: 'key',
name: '',
fn: false,
ctrl: false,
meta: false,
shift: false,
option: false,
super: false,
sequence: content,
raw: content,
isPasted: true,
}
}
/** DECRPM 状态值DECRQM 的响应) */
export const DECRPM_STATUS = {
NOT_RECOGNIZED: 0,
SET: 1,
RESET: 2,
PERMANENTLY_SET: 3,
PERMANENTLY_RESET: 4,
} as const
/**
* 从终端接收的响应序列(不是按键)。
* 作为对 DECRQM、DA1、OSC 11 等查询的回答而发出。
*/
export type TerminalResponse =
/** DECRPMDECRQM 的答案(请求 DEC 私有模式状态) */
| { type: 'decrpm'; mode: number; status: number }
/** DA1主设备属性用作通用哨兵 */
| { type: 'da1'; params: number[] }
/** DA2次设备属性终端版本信息 */
| { type: 'da2'; params: number[] }
/** Kitty 键盘协议当前标志CSI ? u 的答案) */
| { type: 'kittyKeyboard'; flags: number }
/** DSR光标位置报告CSI 6 n 的答案) */
| { type: 'cursorPosition'; row: number; col: number }
/** OSC 响应:通用操作系统命令回复(例如 OSC 11 bg color */
| { type: 'osc'; code: number; data: string }
/** XTVERSION终端名称/版本字符串CSI > 0 q 的答案)。
* 示例值:"xterm.js(5.5.0)""ghostty 1.2.0""iTerm2 3.6"。*/
| { type: 'xtversion'; name: string }
/**
* 尝试将序列标记识别为终端响应。
* 如果序列不是已知响应模式则返回 null即它应该被视为按键
*
* 这些模式在语法上与键盘输入可区分 — 没有物理键产生
* CSI ? ... c 或 CSI ? ... $ y所以它们可以安全地
* 在任何时候从输入流中解析出来。
*/
function parseTerminalResponse(s: string): TerminalResponse | null {
// CSI 前缀的响应
if (s.startsWith('\x1b[')) {
let m: RegExpExecArray | null
if ((m = DECRPM_RE.exec(s))) {
return {
type: 'decrpm',
mode: parseInt(m[1]!, 10),
status: parseInt(m[2]!, 10),
}
}
if ((m = DA1_RE.exec(s))) {
return { type: 'da1', params: splitNumericParams(m[1]!) }
}
if ((m = DA2_RE.exec(s))) {
return { type: 'da2', params: splitNumericParams(m[1]!) }
}
if ((m = KITTY_FLAGS_RE.exec(s))) {
return { type: 'kittyKeyboard', flags: parseInt(m[1]!, 10) }
}
if ((m = CURSOR_POSITION_RE.exec(s))) {
return {
type: 'cursorPosition',
row: parseInt(m[1]!, 10),
col: parseInt(m[2]!, 10),
}
}
return null
}
// OSC 响应(例如 OSC 11 ; rgb:... 用于 bg color 查询)
if (s.startsWith('\x1b]')) {
const m = OSC_RESPONSE_RE.exec(s)
if (m) {
return { type: 'osc', code: parseInt(m[1]!, 10), data: m[2]! }
}
}
// DCS 响应(例如 XTVERSIONDCS > | name ST
if (s.startsWith('\x1bP')) {
const m = XTVERSION_RE.exec(s)
if (m) {
return { type: 'xtversion', name: m[1]! }
}
}
return null
}
function splitNumericParams(params: string): number[] {
if (!params) return []
return params.split(';').map(p => parseInt(p, 10))
}
export type KeyParseState = {
mode: 'NORMAL' | 'IN_PASTE'
incomplete: string
pasteBuffer: string
// 内部分词器实例
_tokenizer?: Tokenizer
}
export const INITIAL_STATE: KeyParseState = {
mode: 'NORMAL',
incomplete: '',
pasteBuffer: '',
}
function inputToString(input: Buffer | string): string {
if (Buffer.isBuffer(input)) {
if (input[0]! > 127 && input[1] === undefined) {
;(input[0] as unknown as number) -= 128
return '\x1b' + String(input)
} else {
return String(input)
}
} else if (input !== undefined && typeof input !== 'string') {
return String(input)
} else if (!input) {
return ''
} else {
return input
}
}
export function parseMultipleKeypresses(
prevState: KeyParseState,
input: Buffer | string | null = '',
): [ParsedInput[], KeyParseState] {
const isFlush = input === null
const inputString = isFlush ? '' : inputToString(input)
// 获取或创建分词器
const tokenizer = prevState._tokenizer ?? createTokenizer({ x10Mouse: true })
// 对输入进行分词
const tokens = isFlush ? tokenizer.flush() : tokenizer.feed(inputString)
// 转换标记为解析的按键,处理粘贴模式
const keys: ParsedInput[] = []
let inPaste = prevState.mode === 'IN_PASTE'
let pasteBuffer = prevState.pasteBuffer
for (const token of tokens) {
if (token.type === 'sequence') {
if (token.value === PASTE_START) {
inPaste = true
pasteBuffer = ''
} else if (token.value === PASTE_END) {
// 始终发出粘贴键,即使是空粘贴。这允许
// 下游处理程序检测空粘贴(例如,用于 macOS 上的剪贴板
// 图像处理)。粘贴内容可能是空字符串。
keys.push(createPasteKey(pasteBuffer))
inPaste = false
pasteBuffer = ''
} else if (inPaste) {
// 粘贴内的序列被视为字面文本
pasteBuffer += token.value
} else {
const response = parseTerminalResponse(token.value)
if (response) {
keys.push({ kind: 'response', sequence: token.value, response })
} else {
const mouse = parseMouseEvent(token.value)
if (mouse) {
keys.push(mouse)
} else {
keys.push(parseKeypress(token.value))
}
}
}
} else if (token.type === 'text') {
if (inPaste) {
pasteBuffer += token.value
} else if (
/^\[<\d+;\d+;\d+[Mm]$/.test(token.value) ||
/^\[M[\x60-\x7f][\x20-\uffff]{2}$/.test(token.value)
) {
// 孤儿 SGR/X10 鼠标尾部(仅全屏 — 否则鼠标追踪关闭)。
// 重型渲染在 App 的 50ms 刷新计时器之后阻塞了事件循环,
// 所以缓冲的 ESC 作为孤立的 Escape 被刷新,
// 后续 `[<btn;col;rowM` 作为文本到达。重新合成
// 带 ESC 前缀的序列,以便滚动事件仍然触发而不是
// 泄漏到提示符中。伪 Escape 消失了App.tsx 的
// readableLength 检查防止它。X10 Cb 槽缩小到
// 轮轮范围 [\x60-\x7f]0x40|modifiers + 32— 完整的 [\x20-]
// 范围会匹配批处理到一个读取中的类型输入如 `[MAX]` 并
// 静默地将其作为幻象点击丢弃。点击/拖动孤儿泄漏
// 为可见乱码代替;可删除的乱码胜于静默丢失。
const resynthesized = '\x1b' + token.value
const mouse = parseMouseEvent(resynthesized)
keys.push(mouse ?? parseKeypress(resynthesized))
} else {
keys.push(parseKeypress(token.value))
}
}
}
// 如果刷新且仍在粘贴模式,发出我们有的内容
if (isFlush && inPaste && pasteBuffer) {
keys.push(createPasteKey(pasteBuffer))
inPaste = false
pasteBuffer = ''
}
// 构建新状态
const newState: KeyParseState = {
mode: inPaste ? 'IN_PASTE' : 'NORMAL',
incomplete: tokenizer.buffer(),
pasteBuffer,
_tokenizer: tokenizer,
}
return [keys, newState]
}
const keyName: Record<string, string> = {
/* xterm/gnome ESC O 字母 */
OP: 'f1',
OQ: 'f2',
OR: 'f3',
OS: 'f4',
/* 应用程序小键盘模式(小键盘数字 0-9 */
Op: '0',
Oq: '1',
Or: '2',
Os: '3',
Ot: '4',
Ou: '5',
Ov: '6',
Ow: '7',
Ox: '8',
Oy: '9',
/* 应用程序小键盘模式(小键盘运算符) */
Oj: '*',
Ok: '+',
Ol: ',',
Om: '-',
On: '.',
Oo: '/',
OM: 'return',
/* xterm/rxvt ESC [ number ~ */
'[11~': 'f1',
'[12~': 'f2',
'[13~': 'f3',
'[14~': 'f4',
/* 来自 Cygwin 并在 libuv 中使用 */
'[[A': 'f1',
'[[B': 'f2',
'[[C': 'f3',
'[[D': 'f4',
'[[E': 'f5',
/* common */
'[15~': 'f5',
'[17~': 'f6',
'[18~': 'f7',
'[19~': 'f8',
'[20~': 'f9',
'[21~': 'f10',
'[23~': 'f11',
'[24~': 'f12',
/* xterm ESC [ 字母 */
'[A': 'up',
'[B': 'down',
'[C': 'right',
'[D': 'left',
'[E': 'clear',
'[F': 'end',
'[H': 'home',
/* xterm/gnome ESC O 字母 */
OA: 'up',
OB: 'down',
OC: 'right',
OD: 'left',
OE: 'clear',
OF: 'end',
OH: 'home',
/* xterm/rxvt ESC [ number ~ */
'[1~': 'home',
'[2~': 'insert',
'[3~': 'delete',
'[4~': 'end',
'[5~': 'pageup',
'[6~': 'pagedown',
/* putty */
'[[5~': 'pageup',
'[[6~': 'pagedown',
/* rxvt */
'[7~': 'home',
'[8~': 'end',
/* rxvt 带修饰符的键 */
'[a': 'up',
'[b': 'down',
'[c': 'right',
'[d': 'left',
'[e': 'clear',
'[2$': 'insert',
'[3$': 'delete',
'[5$': 'pageup',
'[6$': 'pagedown',
'[7$': 'home',
'[8$': 'end',
Oa: 'up',
Ob: 'down',
Oc: 'right',
Od: 'left',
Oe: 'clear',
'[2^': 'insert',
'[3^': 'delete',
'[5^': 'pageup',
'[6^': 'pagedown',
'[7^': 'home',
'[8^': 'end',
/* misc. */
'[Z': 'tab',
}
export const nonAlphanumericKeys = [
// 过滤掉单字符值(数字、小键盘运算符),因为
// 那些是可打印字符,应该产生输入
...Object.values(keyName).filter(v => v.length > 1),
// escape 和 backspace 在 parseKeypress 中直接分配(不是通过
// keyName 映射),所以上面的展开漏掉了它们。没有这些,
// 通过 Kitty/modifyOtherKeys 的 ctrl+escape 会将字面单词 "escape" 泄漏为输入文本
//input-event.ts:58 在设置 ctrl 时分配 keypress.name
'escape',
'backspace',
'wheelup',
'wheeldown',
'mouse',
]
const isShiftKey = (code: string): boolean => {
return [
'[a',
'[b',
'[c',
'[d',
'[e',
'[2$',
'[3$',
'[5$',
'[6$',
'[7$',
'[8$',
'[Z',
].includes(code)
}
const isCtrlKey = (code: string): boolean => {
return [
'Oa',
'Ob',
'Oc',
'Od',
'Oe',
'[2^',
'[3^',
'[5^',
'[6^',
'[7^',
'[8^',
].includes(code)
}
/**
* 解码 XTerm 风格的修饰符值为单独标志。
* 修饰符编码1 + (shift ? 1 : 0) + (alt ? 2 : 0) + (ctrl ? 4 : 0) + (super ? 8 : 0)
*
* 注意:这里的 `meta` 表示 Alt/Option第 2 位)。`super` 是一个不同的
* 修饰符(第 8 位,即 macOS 上的 Cmd / Win 键)。大多数传统终端
* 序列无法表达 super — 它仅通过 kitty 键盘
* 协议CSI u或 xterm modifyOtherKeys 到达。
*/
function decodeModifier(modifier: number): {
shift: boolean
meta: boolean
ctrl: boolean
super: boolean
} {
const m = modifier - 1
return {
shift: !!(m & 1),
meta: !!(m & 2),
ctrl: !!(m & 4),
super: !!(m & 8),
}
}
/**
* 将键码映射到 modifyOtherKeys/CSI u 序列的键名。
* 处理 ASCII 键码和 Kitty 键盘协议功能键。
*
* 小键盘码点来自 Unicode 私有使用区,定义在:
* https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions
*/
function keycodeToName(keycode: number): string | undefined {
switch (keycode) {
case 9:
return 'tab'
case 13:
return 'return'
case 27:
return 'escape'
case 32:
return 'space'
case 127:
return 'backspace'
// Kitty 键盘协议小键盘键KP_0 到 KP_9
case 57399:
return '0'
case 57400:
return '1'
case 57401:
return '2'
case 57402:
return '3'
case 57403:
return '4'
case 57404:
return '5'
case 57405:
return '6'
case 57406:
return '7'
case 57407:
return '8'
case 57408:
return '9'
case 57409: // KP_DECIMAL
return '.'
case 57410: // KP_DIVIDE
return '/'
case 57411: // KP_MULTIPLY
return '*'
case 57412: // KP_SUBTRACT
return '-'
case 57413: // KP_ADD
return '+'
case 57414: // KP_ENTER
return 'return'
case 57415: // KP_EQUAL
return '='
default:
// 可打印 ASCII 字符
if (keycode >= 32 && keycode <= 126) {
return String.fromCharCode(keycode).toLowerCase()
}
return undefined
}
}
export type ParsedKey = {
kind: 'key'
fn: boolean
name: string | undefined
ctrl: boolean
meta: boolean
shift: boolean
option: boolean
super: boolean
sequence: string | undefined
raw: string | undefined
code?: string
isPasted: boolean
}
/** 从输入流中解析出的终端响应序列DECRPM、DA1、OSC 回复等)。
* 不是用户输入 — 消费者应该分派给响应处理器。*/
export type ParsedResponse = {
kind: 'response'
/** 原始转义序列字节,用于调试/日志记录 */
sequence: string
response: TerminalResponse
}
/** 具有坐标的 SGR 鼠标事件。为点击、拖动和
* 释放发出(滚轮事件保持为 ParsedKey。col/row 是
* 来自终端序列的 1 索引CSI < btn;col;row M/m。*/
export type ParsedMouse = {
kind: 'mouse'
/** 原始 SGR 按钮代码。低 2 位 = 按钮0=左1=中2=右),
* 第 5 位0x20= 拖动/运动,第 6 位0x40= 滚轮。*/
button: number
/** 'press' 表示 M 终止符,'release' 表示 m 终止符 */
action: 'press' | 'release'
/** 1 索引列(来自终端) */
col: number
/** 1 索引行(来自终端) */
row: number
sequence: string
}
/** 输入解析器可能输出的所有内容:用户按键/粘贴,
* 鼠标点击/拖动事件,或对我们发送的查询的终端响应。*/
export type ParsedInput = ParsedKey | ParsedMouse | ParsedResponse
/**
* 将 SGR 鼠标事件序列解析为 ParsedMouse如果
* 不是鼠标事件或如果是滚轮事件(滚轮保持为 ParsedKey 用于
* 按键绑定系统),则返回 null。按钮位 0x40 = 滚轮,位 0x20 = 拖动/运动。
*/
function parseMouseEvent(s: string): ParsedMouse | null {
const match = SGR_MOUSE_RE.exec(s)
if (!match) return null
const button = parseInt(match[1]!, 10)
// 滚轮事件(第 6 位设置,低位 0/1 用于上/下)保持为 ParsedKey
// 以便按键绑定系统可以将其路由到滚动处理器。
if ((button & 0x40) !== 0) return null
return {
kind: 'mouse',
button,
action: match[4] === 'M' ? 'press' : 'release',
col: parseInt(match[2]!, 10),
row: parseInt(match[3]!, 10),
sequence: s,
}
}
function parseKeypress(s: string = ''): ParsedKey {
let parts
const key: ParsedKey = {
kind: 'key',
name: '',
fn: false,
ctrl: false,
meta: false,
shift: false,
option: false,
super: false,
sequence: s,
raw: s,
isPasted: false,
}
key.sequence = key.sequence || s || key.name
// 处理 CSI ukitty 键盘协议ESC [ codepoint [; modifier] u
// 示例ESC[13;2u = Shift+EnterESC[27u = Escape无修饰符
let match: RegExpExecArray | null
if ((match = CSI_U_RE.exec(s))) {
const codepoint = parseInt(match[1]!, 10)
// 修饰符默认为 1无修饰符当不存在时
const modifier = match[2] ? parseInt(match[2], 10) : 1
const mods = decodeModifier(modifier)
const name = keycodeToName(codepoint)
return {
kind: 'key',
name,
fn: false,
ctrl: mods.ctrl,
meta: mods.meta,
shift: mods.shift,
option: false,
super: mods.super,
sequence: s,
raw: s,
isPasted: false,
}
}
// 处理 xterm modifyOtherKeysESC [ 27 ; modifier ; keycode ~
// 必须在 FN_KEY_RE 之前运行 — FN_KEY_RE 只允许 ~ 前的 2 个参数,
// 如果部分匹配会留下垃圾。
if ((match = MODIFY_OTHER_KEYS_RE.exec(s))) {
const mods = decodeModifier(parseInt(match[1]!, 10))
const name = keycodeToName(parseInt(match[2]!, 10))
return {
kind: 'key',
name,
fn: false,
ctrl: mods.ctrl,
meta: mods.meta,
shift: mods.shift,
option: false,
super: mods.super,
sequence: s,
raw: s,
isPasted: false,
}
}
// SGR 鼠标滚轮事件。点击/拖动/释放事件由
// parseMouseEvent 更早处理并作为 ParsedMouse 发出,所以它们
// 从不到达这里。用 0x43位 6+1+0掩码以检查滚轮标志
// + 方向同时忽略修饰符位Shift=0x04, Meta=0x08,
// Ctrl=0x10— 修改的滚轮事件(例如 Ctrl+滚动,按钮=80
// 仍然应该被识别为 wheelup/wheeldown。
if ((match = SGR_MOUSE_RE.exec(s))) {
const button = parseInt(match[1]!, 10)
if ((button & 0x43) === 0x40) return createNavKey(s, 'wheelup', false)
if ((button & 0x43) === 0x41) return createNavKey(s, 'wheeldown', false)
// 不应该到达这里parseMouseEvent 捕获非滚轮)但要安全
return createNavKey(s, 'mouse', false)
}
// X10 鼠标CSI M + 3 个原始字节Cb+32, Cx+32, Cy+32
// 忽略 DECSET 1006SGR但遵从 1000/1002 的终端发出这种遗留编码。
// 按钮位与 SGR 匹配0x40 = 滚轮,低位 = 方向。非滚轮
// X10 事件(点击/拖动)在这里被吞掉 — 我们只在 alt-screen
// 启用鼠标追踪,只需要滚轮用于 ScrollBox。
if (s.length === 6 && s.startsWith('\x1b[M')) {
const button = s.charCodeAt(3) - 32
if ((button & 0x43) === 0x40) return createNavKey(s, 'wheelup', false)
if ((button & 0x43) === 0x41) return createNavKey(s, 'wheeldown', false)
return createNavKey(s, 'mouse', false)
}
if (s === '\r') {
key.raw = undefined
key.name = 'return'
} else if (s === '\n') {
key.name = 'enter'
} else if (s === '\t') {
key.name = 'tab'
} else if (s === '\b' || s === '\x1b\b') {
key.name = 'backspace'
key.meta = s.charAt(0) === '\x1b'
} else if (s === '\x7f' || s === '\x1b\x7f') {
key.name = 'backspace'
key.meta = s.charAt(0) === '\x1b'
} else if (s === '\x1b' || s === '\x1b\x1b') {
key.name = 'escape'
key.meta = s.length === 2
} else if (s === ' ' || s === '\x1b ') {
key.name = 'space'
key.meta = s.length === 2
} else if (s === '\x1f') {
key.name = '_'
key.ctrl = true
} else if (s <= '\x1a' && s.length === 1) {
key.name = String.fromCharCode(s.charCodeAt(0) + 'a'.charCodeAt(0) - 1)
key.ctrl = true
} else if (s.length === 1 && s >= '0' && s <= '9') {
key.name = 'number'
} else if (s.length === 1 && s >= 'a' && s <= 'z') {
key.name = s
} else if (s.length === 1 && s >= 'A' && s <= 'Z') {
key.name = s.toLowerCase()
key.shift = true
} else if ((parts = META_KEY_CODE_RE.exec(s))) {
key.meta = true
key.shift = /^[A-Z]$/.test(parts[1]!)
} else if ((parts = FN_KEY_RE.exec(s))) {
const segs = [...s]
if (segs[0] === '\u001b' && segs[1] === '\u001b') {
key.option = true
}
const code = [parts[1], parts[2], parts[4], parts[6]]
.filter(Boolean)
.join('')
const modifier = ((parts[3] || parts[5] || 1) as number) - 1
key.ctrl = !!(modifier & 4)
key.meta = !!(modifier & 2)
key.super = !!(modifier & 8)
key.shift = !!(modifier & 1)
key.code = code
key.name = keyName[code]
key.shift = isShiftKey(code) || key.shift
key.ctrl = isCtrlKey(code) || key.ctrl
}
// iTerm 自然文本编辑模式
if (key.raw === '\x1Bb') {
key.meta = true
key.name = 'left'
} else if (key.raw === '\x1Bf') {
key.meta = true
key.name = 'right'
}
switch (s) {
case '\u001b[1~':
return createNavKey(s, 'home', false)
case '\u001b[4~':
return createNavKey(s, 'end', false)
case '\u001b[5~':
return createNavKey(s, 'pageup', false)
case '\u001b[6~':
return createNavKey(s, 'pagedown', false)
case '\u001b[1;5D':
return createNavKey(s, 'left', true)
case '\u001b[1;5C':
return createNavKey(s, 'right', true)
}
return key
}
function createNavKey(s: string, name: string, ctrl: boolean): ParsedKey {
return {
kind: 'key',
name,
ctrl,
meta: false,
shift: false,
option: false,
super: false,
fn: false,
sequence: s,
raw: s,
isPasted: false,
}
}

View File

@@ -0,0 +1,800 @@
/**
* 键盘输入解析器 - 将终端输入转换为按键事件
*
* 使用 termio 分词器进行转义序列边界检测,
* 然后将序列解释为按键。
*/
import { Buffer } from 'buffer'
import { PASTE_END, PASTE_START } from './termio/csi.js'
import { createTokenizer, type Tokenizer } from './termio/tokenize.js'
// eslint-disable-next-line no-control-regex
const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/
// eslint-disable-next-line no-control-regex
const FN_KEY_RE =
// eslint-disable-next-line no-control-regex
/^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/
// CSI u (kitty 键盘协议): ESC [ 码点 [; 修饰符] u
// 示例: ESC[13;2u = Shift+Enter, ESC[27u = Escape (无修饰符)
// 修饰符是可选的 - 缺席时默认为 1无修饰符
// eslint-disable-next-line no-control-regex
const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/
// xterm modifyOtherKeys: ESC [ 27 ; 修饰符 ; 键码 ~
// 示例: ESC[27;2;13~ = Shift+Enter。当
// modifyOtherKeys=2 激活或通过用户键绑定时由 Ghostty/tmux/xterm 发送,
// 通常通过 SSHTERM 嗅探会错过 Ghostty 且我们永远不会推送 Kitty 键盘模式。
// 注意参数顺序与 CSI u 相反(修饰符在前,键码第二)。
// eslint-disable-next-line no-control-regex
const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/
// -- 终端响应模式(来自终端本身的入站序列)--
// DECRPM: CSI ? Ps ; Pm $ y — 对 DECRQM 的响应(请求模式)
// eslint-disable-next-line no-control-regex
const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/
// DA1: CSI ? Ps ; ... c — 主设备属性响应
// eslint-disable-next-line no-control-regex
const DA1_RE = /^\x1b\[\?([\d;]*)c$/
// DA2: CSI > Ps ; ... c — 次设备属性响应
// eslint-disable-next-line no-control-regex
const DA2_RE = /^\x1b\[>([\d;]*)c$/
// Kitty 键盘标志: CSI ? flags u — 对 CSI ? u 查询的响应
//(私有 ? 标记与 CSI u 按键事件区分)
// eslint-disable-next-line no-control-regex
const KITTY_FLAGS_RE = /^\x1b\[\?(\d+)u$/
// DECXCPR 光标位置: CSI ? row ; col R
// ? 标记与修饰的 F3 键区分Shift+F3 = CSI 1;2 R,
// Ctrl+F3 = CSI 1;5 R 等)— plain CSI row;col R 确实是模糊的。
// eslint-disable-next-line no-control-regex
const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/
// OSC 响应: OSC code ; data (BEL|ST)
// eslint-disable-next-line no-control-regex
const OSC_RESPONSE_RE = /^\x1b\](\d+);(.*?)(?:\x07|\x1b\\)$/s
// XTVERSION: DCS > | name ST — 终端名称/版本字符串(对 CSI > 0 q 的回答)。
// xterm.js 回复 "xterm.js(X.Y.Z)"Ghostty、kitty、iTerm2 等回复
// 自己的名称。与 TERM_PROGRAM 不同,这通过 SSH 存活,因为查询/回复
// 通过 pty 而不是环境传递。
// eslint-disable-next-line no-control-regex
const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
// SGR 鼠标事件: CSI < button ; col ; row M (按下) 或 m (释放)
// 按钮代码: 64=滚轮上, 65=滚轮下 (0x40 | wheel-bit)。
// 按钮 32=左键拖动 (0x20 | motion-bit)。Plain 0/1/2 = 左/中/右键点击。
// eslint-disable-next-line no-control-regex
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
function createPasteKey(content: string): ParsedKey {
return {
kind: 'key',
name: '',
fn: false,
ctrl: false,
meta: false,
shift: false,
option: false,
super: false,
sequence: content,
raw: content,
isPasted: true,
}
}
/** DECRPM 状态值(对 DECRQM 的响应) */
export const DECRPM_STATUS = {
NOT_RECOGNIZED: 0,
SET: 1,
RESET: 2,
PERMANENTLY_SET: 3,
PERMANENTLY_RESET: 4,
} as const
/**
* 从终端接收的响应序列(不是按键)。
* 作为对 DECRQM、DA1、OSC 11 等查询的回答而发出。
*/
export type TerminalResponse =
/** DECRPM: 对 DECRQM 的回答(请求 DEC 私有模式状态) */
| { type: 'decrpm'; mode: number; status: number }
/** DA1: 主设备属性(用作通用哨兵) */
| { type: 'da1'; params: number[] }
/** DA2: 次设备属性(终端版本信息) */
| { type: 'da2'; params: number[] }
/** Kitty 键盘协议: 当前标志(对 CSI ? u 的回答) */
| { type: 'kittyKeyboard'; flags: number }
/** DSR: 光标位置报告(对 CSI 6 n 的回答) */
| { type: 'cursorPosition'; row: number; col: number }
/** OSC 响应: 通用操作系统命令回复(例如 OSC 11 bg 颜色) */
| { type: 'osc'; code: number; data: string }
/** XTVERSION: 终端名称/版本字符串(对 CSI > 0 q 的回答)。
* 示例值: "xterm.js(5.5.0)", "ghostty 1.2.0", "iTerm2 3.6"。 */
| { type: 'xtversion'; name: string }
/**
* 尝试将序列标记识别为终端响应。
* 如果序列不是已知响应模式则返回 null
*(即它应该被视为按键)。
*
* 这些模式在语法上与键盘输入可区分 —
* 没有物理键产生 CSI ? ... c 或 CSI ? ... $ y所以它们可以
* 在任何时候安全地从输入流中解析出来。
*/
function parseTerminalResponse(s: string): TerminalResponse | null {
// CSI 前缀的响应
if (s.startsWith('\x1b[')) {
let m: RegExpExecArray | null
if ((m = DECRPM_RE.exec(s))) {
return {
type: 'decrpm',
mode: parseInt(m[1]!, 10),
status: parseInt(m[2]!, 10),
}
}
if ((m = DA1_RE.exec(s))) {
return { type: 'da1', params: splitNumericParams(m[1]!) }
}
if ((m = DA2_RE.exec(s))) {
return { type: 'da2', params: splitNumericParams(m[1]!) }
}
if ((m = KITTY_FLAGS_RE.exec(s))) {
return { type: 'kittyKeyboard', flags: parseInt(m[1]!, 10) }
}
if ((m = CURSOR_POSITION_RE.exec(s))) {
return {
type: 'cursorPosition',
row: parseInt(m[1]!, 10),
col: parseInt(m[2]!, 10),
}
}
return null
}
// OSC 响应(例如 OSC 11 ; rgb:... 用于 bg 颜色查询)
if (s.startsWith('\x1b]')) {
const m = OSC_RESPONSE_RE.exec(s)
if (m) {
return { type: 'osc', code: parseInt(m[1]!, 10), data: m[2]! }
}
}
// DCS 响应(例如 XTVERSION: DCS > | name ST
if (s.startsWith('\x1bP')) {
const m = XTVERSION_RE.exec(s)
if (m) {
return { type: 'xtversion', name: m[1]! }
}
}
return null
}
function splitNumericParams(params: string): number[] {
if (!params) return []
return params.split(';').map(p => parseInt(p, 10))
}
export type KeyParseState = {
mode: 'NORMAL' | 'IN_PASTE'
incomplete: string
pasteBuffer: string
// 内部 tokenizer 实例
_tokenizer?: Tokenizer
}
export const INITIAL_STATE: KeyParseState = {
mode: 'NORMAL',
incomplete: '',
pasteBuffer: '',
}
function inputToString(input: Buffer | string): string {
if (Buffer.isBuffer(input)) {
if (input[0]! > 127 && input[1] === undefined) {
;(input[0] as unknown as number) -= 128
return '\x1b' + String(input)
} else {
return String(input)
}
} else if (input !== undefined && typeof input !== 'string') {
return String(input)
} else if (!input) {
return ''
} else {
return input
}
}
export function parseMultipleKeypresses(
prevState: KeyParseState,
input: Buffer | string | null = '',
): [ParsedInput[], KeyParseState] {
const isFlush = input === null
const inputString = isFlush ? '' : inputToString(input)
// 获取或创建 tokenizer
const tokenizer = prevState._tokenizer ?? createTokenizer({ x10Mouse: true })
// 对输入进行分词
const tokens = isFlush ? tokenizer.flush() : tokenizer.feed(inputString)
// 转换标记为解析的按键,处理粘贴模式
const keys: ParsedInput[] = []
let inPaste = prevState.mode === 'IN_PASTE'
let pasteBuffer = prevState.pasteBuffer
for (const token of tokens) {
if (token.type === 'sequence') {
if (token.value === PASTE_START) {
inPaste = true
pasteBuffer = ''
} else if (token.value === PASTE_END) {
// 始终发出粘贴按键,即使对于空粘贴也是如此。这允许
// 下游处理程序检测空粘贴(例如,用于 macOS 上的剪贴板
// 图像处理)。粘贴内容可能是空字符串。
keys.push(createPasteKey(pasteBuffer))
inPaste = false
pasteBuffer = ''
} else if (inPaste) {
// 粘贴内的序列被视为纯文本
pasteBuffer += token.value
} else {
const response = parseTerminalResponse(token.value)
if (response) {
keys.push({ kind: 'response', sequence: token.value, response })
} else {
const mouse = parseMouseEvent(token.value)
if (mouse) {
keys.push(mouse)
} else {
keys.push(parseKeypress(token.value))
}
}
}
} else if (token.type === 'text') {
if (inPaste) {
pasteBuffer += token.value
} else if (
/^\[<\d+;\d+;\d+[Mm]$/.test(token.value) ||
/^\[M[\x60-\x7f][\x20-\uffff]{2}$/.test(token.value)
) {
// 孤立的 SGR/X10 鼠标尾部(仅全屏 — 否则鼠标跟踪关闭)。
// 重渲染阻塞了事件循环超过 App 的 50ms
// 刷新定时器,所以缓冲的 ESC 被刷新为单独的 Escape 且
// 后续 `[<btn;col;rowM` 作为文本到达。重新合成
// 带 ESC 前缀,这样滚动事件仍然触发而不是
// 泄漏到提示中。伪 Escape 消失了App.tsx 的
// readableLength 检查防止了它。X10 Cb 槽缩小到
// 轮子范围 [\x60-\x7f] (0x40|modifiers + 32) — 完整的 [\x20-]
// 范围会匹配批量读入的 typed input 如 `[MAX]`
// 并静默地将其作为幻象点击丢弃。点击/拖动孤立事件泄漏
// 为可见垃圾而不是;可删除的垃圾胜过静默丢失。
const resynthesized = '\x1b' + token.value
const mouse = parseMouseEvent(resynthesized)
keys.push(mouse ?? parseKeypress(resynthesized))
} else {
keys.push(parseKeypress(token.value))
}
}
}
// 如果刷新且仍在粘贴模式,发出我们拥有的
if (isFlush && inPaste && pasteBuffer) {
keys.push(createPasteKey(pasteBuffer))
inPaste = false
pasteBuffer = ''
}
// 构建新状态
const newState: KeyParseState = {
mode: inPaste ? 'IN_PASTE' : 'NORMAL',
incomplete: tokenizer.buffer(),
pasteBuffer,
_tokenizer: tokenizer,
}
return [keys, newState]
}
const keyName: Record<string, string> = {
/* xterm/gnome ESC O letter */
OP: 'f1',
OQ: 'f2',
OR: 'f3',
OS: 'f4',
/* Application keypad mode (numpad digits 0-9) */
Op: '0',
Oq: '1',
Or: '2',
Os: '3',
Ot: '4',
Ou: '5',
Ov: '6',
Ow: '7',
Ox: '8',
Oy: '9',
/* Application keypad mode (numpad operators) */
Oj: '*',
Ok: '+',
Ol: ',',
Om: '-',
On: '.',
Oo: '/',
OM: 'return',
/* xterm/rxvt ESC [ number ~ */
'[11~': 'f1',
'[12~': 'f2',
'[13~': 'f3',
'[14~': 'f4',
/* from Cygwin and used in libuv */
'[[A': 'f1',
'[[B': 'f2',
'[[C': 'f3',
'[[D': 'f4',
'[[E': 'f5',
/* common */
'[15~': 'f5',
'[17~': 'f6',
'[18~': 'f7',
'[19~': 'f8',
'[20~': 'f9',
'[21~': 'f10',
'[23~': 'f11',
'[24~': 'f12',
/* xterm ESC [ letter */
'[A': 'up',
'[B': 'down',
'[C': 'right',
'[D': 'left',
'[E': 'clear',
'[F': 'end',
'[H': 'home',
/* xterm/gnome ESC O letter */
OA: 'up',
OB: 'down',
OC: 'right',
OD: 'left',
OE: 'clear',
OF: 'end',
OH: 'home',
/* xterm/rxvt ESC [ number ~ */
'[1~': 'home',
'[2~': 'insert',
'[3~': 'delete',
'[4~': 'end',
'[5~': 'pageup',
'[6~': 'pagedown',
/* putty */
'[[5~': 'pageup',
'[[6~': 'pagedown',
/* rxvt */
'[7~': 'home',
'[8~': 'end',
/* rxvt keys with modifiers */
'[a': 'up',
'[b': 'down',
'[c': 'right',
'[d': 'left',
'[e': 'clear',
'[2$': 'insert',
'[3$': 'delete',
'[5$': 'pageup',
'[6$': 'pagedown',
'[7$': 'home',
'[8$': 'end',
Oa: 'up',
Ob: 'down',
Oc: 'right',
Od: 'left',
Oe: 'clear',
'[2^': 'insert',
'[3^': 'delete',
'[5^': 'pageup',
'[6^': 'pagedown',
'[7^': 'home',
'[8^': 'end',
/* misc. */
'[Z': 'tab',
}
export const nonAlphanumericKeys = [
// 过滤掉单字符值(数字,来自 numpad 的运算符),因为
// 那些是可打印字符,应该产生输入
...Object.values(keyName).filter(v => v.length > 1),
// escape 和 backspace 在 parseKeypress 中直接分配(不通过
// keyName map所以上面的展开错过了它们。没有这些
// ctrl+escape 通过 Kitty/modifyOtherKeys 泄漏字面 "escape" 作为输入文本
//input-event.ts:58 在设置 ctrl 时分配 keypress.name
'escape',
'backspace',
'wheelup',
'wheeldown',
'mouse',
]
const isShiftKey = (code: string): boolean => {
return [
'[a',
'[b',
'[c',
'[d',
'[e',
'[2$',
'[3$',
'[5$',
'[6$',
'[7$',
'[8$',
'[Z',
].includes(code)
}
const isCtrlKey = (code: string): boolean => {
return [
'Oa',
'Ob',
'Oc',
'Od',
'Oe',
'[2^',
'[3^',
'[5^',
'[6^',
'[7^',
'[8^',
].includes(code)
}
/**
* 将 XTerm 风格的修饰符值解码为各个标志。
* 修饰符编码: 1 + (shift ? 1 : 0) + (alt ? 2 : 0) + (ctrl ? 4 : 0) + (super ? 8 : 0)
*
* 注意: 这里的 `meta` 表示 Alt/Option位 2。`super` 是一个不同的
* 修饰符(位 8即 macOS 上的 Cmd / Win 键)。大多数传统终端
* 序列无法表达 super — 它只能通过 kitty 键盘
* 协议CSI u或 xterm modifyOtherKeys 到达。
*/
function decodeModifier(modifier: number): {
shift: boolean
meta: boolean
ctrl: boolean
super: boolean
} {
const m = modifier - 1
return {
shift: !!(m & 1),
meta: !!(m & 2),
ctrl: !!(m & 4),
super: !!(m & 8),
}
}
/**
* 将键码映射到 modifyOtherKeys/CSI u 序列的键名。
* 处理 ASCII 键码和 Kitty 键盘协议功能键。
*
* Numpad 码点来自 Unicode 私有使用区,定义在:
* https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions
*/
function keycodeToName(keycode: number): string | undefined {
switch (keycode) {
case 9:
return 'tab'
case 13:
return 'return'
case 27:
return 'escape'
case 32:
return 'space'
case 127:
return 'backspace'
// Kitty 键盘协议 numpad 键KP_0 到 KP_9
case 57399:
return '0'
case 57400:
return '1'
case 57401:
return '2'
case 57402:
return '3'
case 57403:
return '4'
case 57404:
return '5'
case 57405:
return '6'
case 57406:
return '7'
case 57407:
return '8'
case 57408:
return '9'
case 57409: // KP_DECIMAL
return '.'
case 57410: // KP_DIVIDE
return '/'
case 57411: // KP_MULTIPLY
return '*'
case 57412: // KP_SUBTRACT
return '-'
case 57413: // KP_ADD
return '+'
case 57414: // KP_ENTER
return 'return'
case 57415: // KP_EQUAL
return '='
default:
// 可打印 ASCII 字符
if (keycode >= 32 && keycode <= 126) {
return String.fromCharCode(keycode).toLowerCase()
}
return undefined
}
}
export type ParsedKey = {
kind: 'key'
fn: boolean
name: string | undefined
ctrl: boolean
meta: boolean
shift: boolean
option: boolean
super: boolean
sequence: string | undefined
raw: string | undefined
code?: string
isPasted: boolean
}
/** 从输入流中解析出的终端响应序列DECRPM、DA1、OSC 回复等)。
* 不是用户输入 — 消费者应该分派到响应处理程序。 */
export type ParsedResponse = {
kind: 'response'
/** 原始转义序列字节,用于调试/日志 */
sequence: string
response: TerminalResponse
}
/** 带坐标的 SGR 鼠标事件。为点击、拖动和
* 释放发出(滚轮事件保持 ParsedKey。col/row 是 1-indexed
* 来自终端序列CSI < btn;col;row M/m。 */
export type ParsedMouse = {
kind: 'mouse'
/** 原始 SGR 按钮代码。低 2 位 = 按钮0=左,1=中,2=右),
* 位 50x20= 拖动/移动,位 60x40= 滚轮。 */
button: number
/** 'press' 表示 M 终止符,'release' 表示 m 终止符 */
action: 'press' | 'release'
/** 1-indexed 列(来自终端) */
col: number
/** 1-indexed 行(来自终端) */
row: number
sequence: string
}
/** 输入解析器可能输出的所有内容:用户按键/粘贴,
* 鼠标点击/拖动事件,或对我们发送的查询的终端响应。 */
export type ParsedInput = ParsedKey | ParsedMouse | ParsedResponse
/**
* 将 SGR 鼠标事件序列解析为 ParsedMouse如果它不是
* 鼠标事件或者是滚轮事件(滚轮保持 ParsedKey 用于
* 键绑定系统),则返回 null。按钮位 0x40 = 滚轮,位 0x20 = 拖动/移动。
*/
function parseMouseEvent(s: string): ParsedMouse | null {
const match = SGR_MOUSE_RE.exec(s)
if (!match) return null
const button = parseInt(match[1]!, 10)
// 滚轮事件(设置了位 6低位 0/1 用于上/下)保持为 ParsedKey
// 以便键绑定系统可以将其路由到滚动处理程序。
if ((button & 0x40) !== 0) return null
return {
kind: 'mouse',
button,
action: match[4] === 'M' ? 'press' : 'release',
col: parseInt(match[2]!, 10),
row: parseInt(match[3]!, 10),
sequence: s,
}
}
function parseKeypress(s: string = ''): ParsedKey {
let parts
const key: ParsedKey = {
kind: 'key',
name: '',
fn: false,
ctrl: false,
meta: false,
shift: false,
option: false,
super: false,
sequence: s,
raw: s,
isPasted: false,
}
key.sequence = key.sequence || s || key.name
// 处理 CSI u (kitty 键盘协议): ESC [ 码点 [; 修饰符] u
// 示例: ESC[13;2u = Shift+Enter, ESC[27u = Escape (无修饰符)
let match: RegExpExecArray | null
if ((match = CSI_U_RE.exec(s))) {
const codepoint = parseInt(match[1]!, 10)
// 修饰符在不存在时默认为 1无修饰符
const modifier = match[2] ? parseInt(match[2], 10) : 1
const mods = decodeModifier(modifier)
const name = keycodeToName(codepoint)
return {
kind: 'key',
name,
fn: false,
ctrl: mods.ctrl,
meta: mods.meta,
shift: mods.shift,
option: false,
super: mods.super,
sequence: s,
raw: s,
isPasted: false,
}
}
// 处理 xterm modifyOtherKeys: ESC [ 27 ; 修饰符 ; 键码 ~
// 必须在 FN_KEY_RE 之前运行 — FN_KEY_RE 只允许 ~ 前的 2 个参数,
// 如果部分匹配会将尾部留作垃圾。
if ((match = MODIFY_OTHER_KEYS_RE.exec(s))) {
const mods = decodeModifier(parseInt(match[1]!, 10))
const name = keycodeToName(parseInt(match[2]!, 10))
return {
kind: 'key',
name,
fn: false,
ctrl: mods.ctrl,
meta: mods.meta,
shift: mods.shift,
option: false,
super: mods.super,
sequence: s,
raw: s,
isPasted: false,
}
}
// SGR 鼠标滚轮事件。点击/拖动/释放事件由
// parseMouseEvent 提前处理并作为 ParsedMouse 发出,所以它们
// 从不到达这里。用 0x43位 6+1+0掩码以检查滚轮标志
// + 方向同时忽略修饰符位Shift=0x04, Meta=0x08,
// Ctrl=0x10— 修改的滚轮事件(例如 Ctrl+滚动button=80
// 仍然应该被识别为 wheelup/wheeldown。
if ((match = SGR_MOUSE_RE.exec(s))) {
const button = parseInt(match[1]!, 10)
if ((button & 0x43) === 0x40) return createNavKey(s, 'wheelup', false)
if ((button & 0x43) === 0x41) return createNavKey(s, 'wheeldown', false)
// 不应该到达这里parseMouseEvent 捕获非滚轮)但安全起见
return createNavKey(s, 'mouse', false)
}
// X10 鼠标: CSI M + 3 个原始字节Cb+32, Cx+32, Cy+32。忽略
// DECSET 1006SGR但遵守 1000/1002 的终端发出这个旧编码。
// 按钮位与 SGR 匹配: 0x40 = 滚轮,低位 = 方向。非滚轮
// X10 事件(点击/拖动)在这里被吞掉 — 我们只在 alt-screen
// 启用鼠标跟踪且只需要滚轮用于 ScrollBox。
if (s.length === 6 && s.startsWith('\x1b[M')) {
const button = s.charCodeAt(3) - 32
if ((button & 0x43) === 0x40) return createNavKey(s, 'wheelup', false)
if ((button & 0x43) === 0x41) return createNavKey(s, 'wheeldown', false)
return createNavKey(s, 'mouse', false)
}
if (s === '\r') {
key.raw = undefined
key.name = 'return'
} else if (s === '\n') {
key.name = 'enter'
} else if (s === '\t') {
key.name = 'tab'
} else if (s === '\b' || s === '\x1b\b') {
key.name = 'backspace'
key.meta = s.charAt(0) === '\x1b'
} else if (s === '\x7f' || s === '\x1b\x7f') {
key.name = 'backspace'
key.meta = s.charAt(0) === '\x1b'
} else if (s === '\x1b' || s === '\x1b\x1b') {
key.name = 'escape'
key.meta = s.length === 2
} else if (s === ' ' || s === '\x1b ') {
key.name = 'space'
key.meta = s.length === 2
} else if (s === '\x1f') {
key.name = '_'
key.ctrl = true
} else if (s <= '\x1a' && s.length === 1) {
key.name = String.fromCharCode(s.charCodeAt(0) + 'a'.charCodeAt(0) - 1)
key.ctrl = true
} else if (s.length === 1 && s >= '0' && s <= '9') {
key.name = 'number'
} else if (s.length === 1 && s >= 'a' && s <= 'z') {
key.name = s
} else if (s.length === 1 && s >= 'A' && s <= 'Z') {
key.name = s.toLowerCase()
key.shift = true
} else if ((parts = META_KEY_CODE_RE.exec(s))) {
key.meta = true
key.shift = /^[A-Z]$/.test(parts[1]!)
} else if ((parts = FN_KEY_RE.exec(s))) {
const segs = [...s]
if (segs[0] === '\u001b' && segs[1] === '\u001b') {
key.option = true
}
const code = [parts[1], parts[2], parts[4], parts[6]]
.filter(Boolean)
.join('')
const modifier = ((parts[3] || parts[5] || 1) as number) - 1
key.ctrl = !!(modifier & 4)
key.meta = !!(modifier & 2)
key.super = !!(modifier & 8)
key.shift = !!(modifier & 1)
key.code = code
key.name = keyName[code]
key.shift = isShiftKey(code) || key.shift
key.ctrl = isCtrlKey(code) || key.ctrl
}
// iTerm 自然文本编辑模式
if (key.raw === '\x1Bb') {
key.meta = true
key.name = 'left'
} else if (key.raw === '\x1Bf') {
key.meta = true
key.name = 'right'
}
switch (s) {
case '\u001b[1~':
return createNavKey(s, 'home', false)
case '\u001b[4~':
return createNavKey(s, 'end', false)
case '\u001b[5~':
return createNavKey(s, 'pageup', false)
case '\u001b[6~':
return createNavKey(s, 'pagedown', false)
case '\u001b[1;5D':
return createNavKey(s, 'left', true)
case '\u001b[1;5C':
return createNavKey(s, 'right', true)
}
return key
}
function createNavKey(s: string, name: string, ctrl: boolean): ParsedKey {
return {
kind: 'key',
name,
ctrl,
meta: false,
shift: false,
option: false,
super: false,
fn: false,
sequence: s,
raw: s,
isPasted: false,
}
}

View File

@@ -0,0 +1,174 @@
import { logForDebugging } from 'src/utils/debug.js'
import { type DOMElement, markDirty } from './dom.js'
import type { Frame } from './frame.js'
import { consumeAbsoluteRemovedFlag } from './node-cache.js'
import Output from './output.js'
import renderNodeToOutput, {
getScrollDrainNode,
getScrollHint,
resetLayoutShifted,
resetScrollDrainNode,
resetScrollHint,
} from './render-node-to-output.js'
import { createScreen, type StylePool } from './screen.js'
export type RenderOptions = {
frontFrame: Frame
backFrame: Frame
isTTY: boolean
terminalWidth: number
terminalRows: number
altScreen: boolean
// 上一帧的屏幕缓冲区在渲染后被改变(选择覆盖)为 true
// 重置为空白alt-screen 进入/调整大小/SIGCONT
// 或重置为 0×0forceRedraw。从这样的 prevScreen blit 会
// 复制过时的反转单元格、空白或什么都没有。为 false 时blit 是安全的。
prevFrameContaminated: boolean
}
export type Renderer = (options: RenderOptions) => Frame
export default function createRenderer(
node: DOMElement,
stylePool: StylePool,
): Renderer {
// 跨帧重用 Output 以便 charCache分词 + 字素聚类)持久化 —
// 大多数行在渲染之间不改变。
let output: Output | undefined
return options => {
const { frontFrame, backFrame, isTTY, terminalWidth, terminalRows } =
options
const prevScreen = frontFrame.screen
const backScreen = backFrame.screen
// 从后缓冲区的屏幕读取池 — 池可能在帧之间被替换
//(代际重置),所以我们不能在闭包中捕获它们
const charPool = backScreen.charPool
const hyperlinkPool = backScreen.hyperlinkPool
// 如果 yoga 节点不存在或布局尚未计算,则返回空帧。
// getComputedHeight() 在调用 calculateLayout() 之前返回 NaN。
// 还要检查会导致创建数组时 RangeError 的无效尺寸负数、Infinity
const computedHeight = node.yogaNode?.getComputedHeight()
const computedWidth = node.yogaNode?.getComputedWidth()
const hasInvalidHeight =
computedHeight === undefined ||
!Number.isFinite(computedHeight) ||
computedHeight < 0
const hasInvalidWidth =
computedWidth === undefined ||
!Number.isFinite(computedWidth) ||
computedWidth < 0
if (!node.yogaNode || hasInvalidHeight || hasInvalidWidth) {
// 记录以帮助诊断根本原因(使用 --debug 标志可见)
if (node.yogaNode && (hasInvalidHeight || hasInvalidWidth)) {
logForDebugging(
`Invalid yoga dimensions: width=${computedWidth}, height=${computedHeight}, ` +
`childNodes=${node.childNodes.length}, terminalWidth=${terminalWidth}, terminalRows=${terminalRows}`,
)
}
return {
screen: createScreen(
terminalWidth,
0,
stylePool,
charPool,
hyperlinkPool,
),
viewport: { width: terminalWidth, height: terminalRows },
cursor: { x: 0, y: 0, visible: true },
}
}
const width = Math.floor(node.yogaNode.getComputedWidth())
const yogaHeight = Math.floor(node.yogaNode.getComputedHeight())
// Alt-screen屏幕缓冲区就是 alt 缓冲区 — 始终恰好是
// terminalRows 高度。<AlternateScreen> 用 <Box
// height={rows} flexShrink={0}> 包装子项,所以 yogaHeight 应该等于
// terminalRows。但如果某些东西渲染为该 Box 的 SIBLING
//bugMessageSelector 在 <FullscreenLayout> 之外yogaHeight
// 超过 rows下面的每个假设viewport +1 hack、cursor.y
// clamp、log-update 的 heightDelta===0 快路径)都会破坏,
// 使虚拟/物理光标不同步。在这里夹紧强制执行不变量:
// 溢出写入落在 y >= screen.heightsetCellAt 丢弃它们。
// 兄弟节点不可见(明显、易于查找),而不是
// 破坏整个终端。
const height = options.altScreen ? terminalRows : yogaHeight
if (options.altScreen && yogaHeight > terminalRows) {
logForDebugging(
`alt-screen: yoga height ${yogaHeight} > terminalRows ${terminalRows}` +
`something is rendering outside <AlternateScreen>. Overflow clipped.`,
{ level: 'warn' },
)
}
const screen =
backScreen ??
createScreen(width, height, stylePool, charPool, hyperlinkPool)
if (output) {
output.reset(width, height, screen)
} else {
output = new Output({ width, height, stylePool, screen })
}
resetLayoutShifted()
resetScrollHint()
resetScrollDrainNode()
// prevFrameContaminated选择覆盖在渲染后改变了返回的屏幕
// 缓冲区(在 ink.tsx 中resetFramesForAltScreen() 用空白替换它,
// 或 forceRedraw() 将其重置为 0×0。在下一帧上 Blit 会
// 复制过时的反转单元格 / 空白 / 什么都没有。当干净时blit
// 为稳定状态帧恢复 O(不变) 快路径(微调刻度、文本流)。
// 移除绝对定位节点会使 prevScreen 中毒:它可能已在非兄弟节点上
// 绘制(例如树顺序中较早的 ScrollBox 上的覆盖),
// 所以它们的 blits 会恢复已移除节点的像素。hasRemovedChild 仅保护直接兄弟。
// 正常流移除不绘制跨子树,没问题。
const absoluteRemoved = consumeAbsoluteRemovedFlag()
renderNodeToOutput(node, output, {
prevScreen:
absoluteRemoved || options.prevFrameContaminated
? undefined
: prevScreen,
})
const renderedScreen = output.get()
// 排空延续:渲染清除 scrollbox.dirty所以下一帧的
// 根 blit 会跳过子树。markDirty 向上遍历祖先,所以
// 下一帧下降。在渲染之后完成,以便 renderNodeToOutput 末尾的
// clear-dirty 不会覆盖这个。
const drainNode = getScrollDrainNode()
if (drainNode) markDirty(drainNode)
return {
scrollHint: options.altScreen ? getScrollHint() : null,
scrollDrainPending: drainNode !== null,
screen: renderedScreen,
viewport: {
width: terminalWidth,
// Alt screen伪 viewport.height = rows + 1 使得
// shouldClearScreen() 的 `screen.height >= viewport.height` 检查
//(将恰好填充的内容视为"溢出"用于滚动目的)永远不会触发。
// Alt-screen 内容始终恰好是 `rows` 高(通过 <Box height={rows}>
// 但从不滚动 — 光标.y clamp 在下面保持光标恢复
// 不会发出 LF。使用标准 diff 路径,每帧都是增量;
// 没有 fullResetSequence_CAUSES_FLICKER。
height: options.altScreen ? terminalRows + 1 : terminalRows,
},
cursor: {
x: 0,
// 在 alt screen 中,将光标保持在视口内。当
// screen.height === terminalRows 完全(内容填充 alt
// screencursor.y = screen.height 会触发 log-update 的
// 光标恢复 LF 在最后一行,将 alt 缓冲区的一行滚动出顶部
// 并使 diff 的光标模型不同步。光标是隐藏的,
// 所以它的位置只对 diff 坐标重要。
y: options.altScreen
? Math.max(0, Math.min(screen.height, terminalRows) - 1)
: screen.height,
// 当有动态输出要渲染时隐藏光标(仅在 TTY 模式)
visible: !isTTY || screen.height === 0,
},
}
}
}

View File

@@ -0,0 +1,243 @@
import chalk from 'chalk'
import cliBoxes, { type Boxes, type BoxStyle } from 'cli-boxes'
import { applyColor } from './colorize.js'
import type { DOMNode } from './dom.js'
import type Output from './output.js'
import { stringWidth } from './stringWidth.js'
import type { Color } from './styles.js'
export type BorderTextOptions = {
content: string // 预渲染的字符串,包含 ANSI 颜色代码
position: 'top' | 'bottom'
align: 'start' | 'end' | 'center'
offset?: number // 仅与 'start' 或 'end' 对齐一起使用。从边缘开始的字符数。
}
export const CUSTOM_BORDER_STYLES = {
dashed: {
top: '╌',
left: '╎',
right: '╎',
bottom: '╌',
// 遗憾的是没有用于虚线的线条绘制字符
topLeft: ' ',
topRight: ' ',
bottomLeft: ' ',
bottomRight: ' ',
},
} as const
export type BorderStyle =
| keyof Boxes
| keyof typeof CUSTOM_BORDER_STYLES
| BoxStyle
/**
* 将文本嵌入边框线中。
* 根据对齐方式和偏移量计算文本在边框中的位置。
*/
function embedTextInBorder(
borderLine: string,
text: string,
align: 'start' | 'end' | 'center',
offset: number = 0,
borderChar: string,
): [before: string, text: string, after: string] {
const textLength = stringWidth(text)
const borderLength = borderLine.length
if (textLength >= borderLength - 2) {
return ['', text.substring(0, borderLength), '']
}
let position: number
if (align === 'center') {
position = Math.floor((borderLength - textLength) / 2)
} else if (align === 'start') {
position = offset + 1 // +1 以考虑角落字符
} else {
// align === 'end'
position = borderLength - textLength - offset - 1 // -1 表示角落字符
}
// 确保位置有效
position = Math.max(1, Math.min(position, borderLength - textLength - 1))
const before = borderLine.substring(0, 1) + borderChar.repeat(position - 1)
const after =
borderChar.repeat(borderLength - position - textLength - 1) +
borderLine.substring(borderLength - 1)
return [before, text, after]
}
/**
* 应用颜色和dim样式到边框线。
*/
function styleBorderLine(
line: string,
color: Color | undefined,
dim: boolean | undefined,
): string {
let styled = applyColor(line, color)
if (dim) {
styled = chalk.dim(styled)
}
return styled
}
/**
* 渲染边框的主函数。
* 根据节点的 borderStyle 属性绘制边框。
*/
const renderBorder = (
x: number,
y: number,
node: DOMNode,
output: Output,
): void => {
if (node.style.borderStyle) {
const width = Math.floor(node.yogaNode!.getComputedWidth())
const height = Math.floor(node.yogaNode!.getComputedHeight())
const box =
typeof node.style.borderStyle === 'string'
? (CUSTOM_BORDER_STYLES[
node.style.borderStyle as keyof typeof CUSTOM_BORDER_STYLES
] ?? cliBoxes[node.style.borderStyle as keyof Boxes])
: node.style.borderStyle
const topBorderColor = node.style.borderTopColor ?? node.style.borderColor
const bottomBorderColor =
node.style.borderBottomColor ?? node.style.borderColor
const leftBorderColor =
node.style.borderLeftColor ?? node.style.borderColor
const rightBorderColor =
node.style.borderRightColor ?? node.style.borderColor
const dimTopBorderColor =
node.style.borderTopDimColor ?? node.style.borderDimColor
const dimBottomBorderColor =
node.style.borderBottomDimColor ?? node.style.borderDimColor
const dimLeftBorderColor =
node.style.borderLeftDimColor ?? node.style.borderDimColor
const dimRightBorderColor =
node.style.borderRightDimColor ?? node.style.borderDimColor
const showTopBorder = node.style.borderTop !== false
const showBottomBorder = node.style.borderBottom !== false
const showLeftBorder = node.style.borderLeft !== false
const showRightBorder = node.style.borderRight !== false
const contentWidth = Math.max(
0,
width - (showLeftBorder ? 1 : 0) - (showRightBorder ? 1 : 0),
)
const topBorderLine = showTopBorder
? (showLeftBorder ? box.topLeft : '') +
box.top.repeat(contentWidth) +
(showRightBorder ? box.topRight : '')
: ''
// 处理顶部边框中的文本
let topBorder: string | undefined
if (showTopBorder && node.style.borderText?.position === 'top') {
const [before, text, after] = embedTextInBorder(
topBorderLine,
node.style.borderText.content,
node.style.borderText.align,
node.style.borderText.offset,
box.top,
)
topBorder =
styleBorderLine(before, topBorderColor, dimTopBorderColor) +
text +
styleBorderLine(after, topBorderColor, dimTopBorderColor)
} else if (showTopBorder) {
topBorder = styleBorderLine(
topBorderLine,
topBorderColor,
dimTopBorderColor,
)
}
let verticalBorderHeight = height
if (showTopBorder) {
verticalBorderHeight -= 1
}
if (showBottomBorder) {
verticalBorderHeight -= 1
}
verticalBorderHeight = Math.max(0, verticalBorderHeight)
let leftBorder = (applyColor(box.left, leftBorderColor) + '\n').repeat(
verticalBorderHeight,
)
if (dimLeftBorderColor) {
leftBorder = chalk.dim(leftBorder)
}
let rightBorder = (applyColor(box.right, rightBorderColor) + '\n').repeat(
verticalBorderHeight,
)
if (dimRightBorderColor) {
rightBorder = chalk.dim(rightBorder)
}
const bottomBorderLine = showBottomBorder
? (showLeftBorder ? box.bottomLeft : '') +
box.bottom.repeat(contentWidth) +
(showRightBorder ? box.bottomRight : '')
: ''
// 处理底部边框中的文本
let bottomBorder: string | undefined
if (showBottomBorder && node.style.borderText?.position === 'bottom') {
const [before, text, after] = embedTextInBorder(
bottomBorderLine,
node.style.borderText.content,
node.style.borderText.align,
node.style.borderText.offset,
box.bottom,
)
bottomBorder =
styleBorderLine(before, bottomBorderColor, dimBottomBorderColor) +
text +
styleBorderLine(after, bottomBorderColor, dimBottomBorderColor)
} else if (showBottomBorder) {
bottomBorder = styleBorderLine(
bottomBorderLine,
bottomBorderColor,
dimBottomBorderColor,
)
}
const offsetY = showTopBorder ? 1 : 0
if (topBorder) {
output.write(x, y, topBorder)
}
if (showLeftBorder) {
output.write(x, y + offsetY, leftBorder)
}
if (showRightBorder) {
output.write(x + width - 1, y + offsetY, rightBorder)
}
if (bottomBorder) {
output.write(x, y + height - 1, bottomBorder)
}
}
}
export default renderBorder

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
import type { DOMElement } from './dom.js'
import type { TextStyles } from './styles.js'
/**
* 具有关联样式的文本段。
* 用于不依赖 ANSI 字符串转换的结构化渲染。
*/
export type StyledSegment = {
text: string
styles: TextStyles
hyperlink?: string
}
/**
* 将文本节点压缩为样式段,沿着树向下传播样式。
* 这允许结构化样式而不依赖 ANSI 字符串转换。
*/
export function squashTextNodesToSegments(
node: DOMElement,
inheritedStyles: TextStyles = {},
inheritedHyperlink?: string,
out: StyledSegment[] = [],
): StyledSegment[] {
const mergedStyles = node.textStyles
? { ...inheritedStyles, ...node.textStyles }
: inheritedStyles
for (const childNode of node.childNodes) {
if (childNode === undefined) {
continue
}
if (childNode.nodeName === '#text') {
if (childNode.nodeValue.length > 0) {
out.push({
text: childNode.nodeValue,
styles: mergedStyles,
hyperlink: inheritedHyperlink,
})
}
} else if (
childNode.nodeName === 'ink-text' ||
childNode.nodeName === 'ink-virtual-text'
) {
squashTextNodesToSegments(
childNode,
mergedStyles,
inheritedHyperlink,
out,
)
} else if (childNode.nodeName === 'ink-link') {
const href = childNode.attributes['href'] as string | undefined
squashTextNodesToSegments(
childNode,
mergedStyles,
href || inheritedHyperlink,
out,
)
}
}
return out
}
/**
* 将文本节点压缩为纯字符串(无样式)。
* 用于布局计算中的文本测量。
*/
function squashTextNodes(node: DOMElement): string {
let text = ''
for (const childNode of node.childNodes) {
if (childNode === undefined) {
continue
}
if (childNode.nodeName === '#text') {
text += childNode.nodeValue
} else if (
childNode.nodeName === 'ink-text' ||
childNode.nodeName === 'ink-virtual-text'
) {
text += squashTextNodes(childNode)
} else if (childNode.nodeName === 'ink-link') {
text += squashTextNodes(childNode)
}
}
return text
}
export default squashTextNodes

View File

@@ -0,0 +1,185 @@
import type { ReactNode } from 'react'
import { logForDebugging } from 'src/utils/debug.js'
import { Stream } from 'stream'
import type { FrameEvent } from './frame.js'
import Ink, { type Options as InkOptions } from './ink.js'
import instances from './instances.js'
export type RenderOptions = {
/**
* 应用将渲染到的输出流。
*
* @default process.stdout
*/
stdout?: NodeJS.WriteStream
/**
* 应用监听输入的输入流。
*
* @default process.stdin
*/
stdin?: NodeJS.ReadStream
/**
* 错误流。
* @default process.stderr
*/
stderr?: NodeJS.WriteStream
/**
* 配置 Ink 是否应该监听 Ctrl+C 键盘输入并退出应用。这在 `process.stdin` 处于原始模式时是需要的,
* 因为默认情况下 Ctrl+C 被忽略,进程需要手动处理。
*
* @default true
*/
exitOnCtrlC?: boolean
/**
* 修补 console 方法以确保 console 输出不会与 Ink 输出混合。
*
* @default true
*/
patchConsole?: boolean
/**
* 在每帧渲染后调用,包含时序和闪烁信息。
*/
onFrame?: (event: FrameEvent) => void
}
export type Instance = {
/**
* 用新的替换之前的根节点或更新当前根节点的 props。
*/
rerender: Ink['render']
/**
* 手动卸载整个 Ink 应用。
*/
unmount: Ink['unmount']
/**
* 返回一个 promise在应用卸载时 resolve。
*/
waitUntilExit: Ink['waitUntilExit']
cleanup: () => void
}
/**
* 托管的 Ink 根,类似于 react-dom 的 createRoot API。
* 将实例创建与渲染分离,以便同一个根可以
* 重用于多个顺序屏幕。
*/
export type Root = {
render: (node: ReactNode) => void
unmount: () => void
waitUntilExit: () => Promise<void>
}
/**
* 挂载一个组件并渲染输出。
*/
export const renderSync = (
node: ReactNode,
options?: NodeJS.WriteStream | RenderOptions,
): Instance => {
const opts = getOptions(options)
const inkOptions: InkOptions = {
stdout: process.stdout,
stdin: process.stdin,
stderr: process.stderr,
exitOnCtrlC: true,
patchConsole: true,
...opts,
}
const instance: Ink = getInstance(
inkOptions.stdout,
() => new Ink(inkOptions),
)
instance.render(node)
return {
rerender: instance.render,
unmount() {
instance.unmount()
},
waitUntilExit: instance.waitUntilExit,
cleanup: () => instances.delete(inkOptions.stdout),
}
}
const wrappedRender = async (
node: ReactNode,
options?: NodeJS.WriteStream | RenderOptions,
): Promise<Instance> => {
// 保留 `await loadYoga()` 曾经提供的微任务边界。
// 没有它,第一次渲染在异步启动工作
//(例如 useReplBridge 通知状态)稳定之前同步触发,
// 后续的 Static 写入覆盖滚动缓冲区而不是附加到 logo 下方。
await Promise.resolve()
const instance = renderSync(node, options)
logForDebugging(
`[render] first ink render: ${Math.round(process.uptime() * 1000)}ms since process start`,
)
return instance
}
export default wrappedRender
/**
* 创建一个不渲染任何内容的 Ink 根。
* 像 react-dom 的 createRoot — 调用 root.render() 来挂载一棵树。
*/
export async function createRoot({
stdout = process.stdout,
stdin = process.stdin,
stderr = process.stderr,
exitOnCtrlC = true,
patchConsole = true,
onFrame,
}: RenderOptions = {}): Promise<Root> {
// 见 wrappedRender — 保留旧 WASM await 的微任务边界。
await Promise.resolve()
const instance = new Ink({
stdout,
stdin,
stderr,
exitOnCtrlC,
patchConsole,
onFrame,
})
// 在实例映射中注册,以便通过 stdout 查找 Ink
// 实例的代码(例如外部编辑器暂停/恢复)可以找到它。
instances.set(stdout, instance)
return {
render: node => instance.render(node),
unmount: () => instance.unmount(),
waitUntilExit: () => instance.waitUntilExit(),
}
}
const getOptions = (
stdout: NodeJS.WriteStream | RenderOptions | undefined = {},
): RenderOptions => {
if (stdout instanceof Stream) {
return {
stdout,
stdin: process.stdin,
}
}
return stdout
}
const getInstance = (
stdout: NodeJS.WriteStream,
createInstance: () => Ink,
): Ink => {
let instance = instances.get(stdout)
if (!instance) {
instance = createInstance()
instances.set(stdout, instance)
}
return instance
}

View File

@@ -0,0 +1,199 @@
/* eslint-disable custom-rules/no-top-level-side-effects */
import { appendFileSync } from 'fs'
import createReconciler from 'react-reconciler'
import { getYogaCounters } from 'src/native-ts/yoga-layout/index.js'
import { isEnvTruthy } from '../utils/envUtils.js'
import {
appendChildNode,
clearYogaNodeReferences,
createNode,
createTextNode,
type DOMElement,
type DOMNodeAttribute,
type ElementNames,
insertBeforeNode,
markDirty,
removeChildNode,
setAttribute,
setStyle,
setTextNodeValue,
setTextStyles,
type TextNode,
} from './dom.js'
import { Dispatcher } from './events/dispatcher.js'
import { EVENT_HANDLER_PROPS } from './events/event-handlers.js'
import { getFocusManager, getRootNode } from './focus.js'
import { LayoutDisplay } from './layout/node.js'
import applyStyles, { type Styles, type TextStyles } from './styles.js'
// 我们需要有条件地执行 devtools 连接以避免
// 意外破坏其他第三方代码。
// 见 https://github.com/vadimdemedes/ink/issues/384
if (process.env.NODE_ENV === 'development') {
try {
// eslint-disable-next-line custom-rules/no-top-level-dynamic-import -- dev-only; NODE_ENV check is DCE'd in production
void import('./devtools.js')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
if (error.code === 'ERR_MODULE_NOT_FOUND') {
// biome-ignore lint/suspicious/noConsole: intentional warning
console.warn(
`
环境变量 DEV 设置为 true因此 Ink 尝试导入 \`react-devtools-core\`
但这失败了,因为没有安装。使用 React Devtools 调试需要它。
安装使用此命令:
$ npm install --save-dev react-devtools-core
`.trim() + '\n',
)
} else {
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw error
}
}
}
// --
type AnyObject = Record<string, unknown>
const diff = (before: AnyObject, after: AnyObject): AnyObject | undefined => {
if (before === after) {
return
}
if (!before) {
return after
}
const changed: AnyObject = {}
let isChanged = false
for (const key of Object.keys(before)) {
const isDeleted = after ? !Object.hasOwn(after, key) : true
if (isDeleted) {
changed[key] = undefined
isChanged = true
}
}
if (after) {
for (const key of Object.keys(after)) {
if (after[key] !== before[key]) {
changed[key] = after[key]
isChanged = true
}
}
}
return isChanged ? changed : undefined
}
const cleanupYogaNode = (node: DOMElement | TextNode): void => {
const yogaNode = node.yogaNode
if (yogaNode) {
yogaNode.unsetMeasureFunc()
// 在释放之前清除所有引用,以防止其他代码在并发操作期间访问已释放的 WASM 内存
clearYogaNodeReferences(node)
yogaNode.freeRecursive()
}
}
// --
type Props = Record<string, unknown>
type HostContext = {
isInsideText: boolean
}
function setEventHandler(node: DOMElement, key: string, value: unknown): void {
if (!node._eventHandlers) {
node._eventHandlers = {}
}
node._eventHandlers[key] = value
}
function applyProp(node: DOMElement, key: string, value: unknown): void {
if (key === 'children') return
if (key === 'style') {
setStyle(node, value as Styles)
if (node.yogaNode) {
applyStyles(node.yogaNode, value as Styles)
}
return
}
if (key === 'textStyles') {
node.textStyles = value as TextStyles
return
}
if (EVENT_HANDLER_PROPS.has(key)) {
setEventHandler(node, key, value)
return
}
setAttribute(node, key, value as DOMNodeAttribute)
}
// --
// react-reconciler 的 Fiber 形状 — 我们遍历的仅是字段。
// createInstance 的第 5 个参数是 Fiber在 react-reconciler.dev.js 中是 workInProgress
// _debugOwner 是渲染此元素的组件(仅限开发构建);
// return 是父 fiber始终存在。我们更喜欢 _debugOwner
// 因为它跳过 Box/Text 包装器到实际的命名组件。
type FiberLike = {
elementType?: { displayName?: string; name?: string } | string | null
_debugOwner?: FiberLike | null
return?: FiberLike | null
}
export function getOwnerChain(fiber: unknown): string[] {
const chain: string[] = []
const seen = new Set<unknown>()
let cur = fiber as FiberLike | null | undefined
for (let i = 0; cur && i < 50; i++) {
if (seen.has(cur)) break
seen.add(cur)
const t = cur.elementType
const name =
typeof t === 'function'
? (t as { displayName?: string; name?: string }).displayName ||
(t as { displayName?: string; name?: string }).name
: typeof t === 'string'
? undefined // host element (ink-box etc) — skip
: t?.displayName || t?.name
if (name && name !== chain[chain.length - 1]) chain.push(name)
cur = cur._debugOwner ?? cur.return
}
return chain
}
let debugRepaints: boolean | undefined
export function isDebugRepaintsEnabled(): boolean {
if (debugRepaints === undefined) {
debugRepaints = isEnvTruthy(process.env.CLAUDE_CODE_DEBUG_REPAINTS)
}
return debugRepaints
}
export const dispatcher = new Dispatcher()
// --- 提交检测(临时调试)---
// eslint-disable-next-line custom-rules/no-process-env-top-level -- debug instrumentation, read-once is fine
const COMMIT_LOG = process.env.CLAUDE_CODE_COMMIT_LOG
let _commits = 0
let _lastLog = 0
let _lastCommitAt = 0
let _maxGapMs = 0
let _createCount = 0
let _prepareAt = 0
// --- 结束 ---
// --- 滚动性能分析bench/scroll-e2e.sh 通过 getLastYogaMs 读取)---

View File

@@ -0,0 +1,296 @@
import {
type AnsiCode,
ansiCodesToString,
diffAnsiCodes,
} from '@alcalzone/ansi-tokenize'
import {
type Point,
type Rectangle,
type Size,
unionRect,
} from './layout/geometry.js'
import { BEL, ESC, SEP } from './termio/ansi.js'
import * as warn from './warn.js'
// --- 共享池(用于内存效率的驻留)---
// 字符字符串池在所有屏幕之间共享。
// 使用共享池,驻留的字符 ID 在屏幕之间有效,
// 所以 blitRegion 可以直接复制 ID无需重新驻留
// diffEach 可以将 ID 作为整数比较(无需字符串查找)。
export class CharPool {
private strings: string[] = [' ', ''] // 索引 0 = 空格1 = 空spacer
private stringMap = new Map<string, number>([
[' ', 0],
['', 1],
])
private ascii: Int32Array = initCharAscii() // charCode → index-1 = 未驻留
intern(char: string): number {
// ASCII 快速路径:直接数组查找而不是 Map.get
if (char.length === 1) {
const code = char.charCodeAt(0)
if (code < 128) {
const cached = this.ascii[code]!
if (cached !== -1) return cached
const index = this.strings.length
this.strings.push(char)
this.ascii[code] = index
return index
}
}
const existing = this.stringMap.get(char)
if (existing !== undefined) return existing
const index = this.strings.length
this.strings.push(char)
this.stringMap.set(char, index)
return index
}
get(index: number): string {
return this.strings[index] ?? ' '
}
}
// 超链接字符串池在所有屏幕之间共享。
// 索引 0 = 无超链接。
export class HyperlinkPool {
private strings: string[] = [''] // 索引 0 = 无超链接
private stringMap = new Map<string, number>()
intern(hyperlink: string | undefined): number {
if (!hyperlink) return 0
let id = this.stringMap.get(hyperlink)
if (id === undefined) {
id = this.strings.length
this.strings.push(hyperlink)
this.stringMap.set(hyperlink, id)
}
return id
}
get(id: number): string | undefined {
return id === 0 ? undefined : this.strings[id]
}
}
// SGR 7反转作为 AnsiCode。endCode '\x1b[27m' 标志 VISIBLE_ON_SPACE
// 所以结果 styleId 的第 0 位被设置 → 渲染器不会跳过反转的
// 空格为不可见。
const INVERSE_CODE: AnsiCode = {
type: 'ansi',
code: '\x1b[7m',
endCode: '\x1b[27m',
}
// BoldSGR 1— 干净地堆叠在等宽字体中无重排。endCode 22
// 也取消 dimSGR 2无害因为我们从不添加 dim。
const BOLD_CODE: AnsiCode = {
type: 'ansi',
code: '\x1b[1m',
endCode: '\x1b[22m',
}
// UnderlineSGR 4。与 yellow+bold 一起保留 — 下划线是
// 在任何主题上清晰可见的标记。Yellow-bg-via-inverse 可以
// 与现有 bg 颜色冲突(用户提示样式、工具 chrome、语法
// bg。如果你看到下划线但没有黄色黄色在现有单元格样式中丢失
// — 覆盖确实找到了匹配。
const UNDERLINE_CODE: AnsiCode = {
type: 'ansi',
code: '\x1b[4m',
endCode: '\x1b[24m',
}
// fg→yellowSGR 33。当 inverse 已在堆栈中,终端
// 交换 fg↔bg 渲染 — 所以 yellow-fg 变成 yellow-BG。原始 bg
// 变成 fg在大多主题上可读dark-bg → dark-text on yellow
// endCode 39 是 'default fg' — 干净地取消任何先前 fg 颜色。
const YELLOW_FG_CODE: AnsiCode = {
type: 'ansi',
code: '\x1b[33m',
endCode: '\x1b[39m',
}
export class StylePool {
private ids = new Map<string, number>()
private styles: AnsiCode[][] = []
private transitionCache = new Map<number, string>()
readonly none: number
constructor() {
this.none = this.intern([])
}
/**
* 驻留样式并返回其 ID。ID 的第 0 位编码样式是否对
* 空格字符有可见效果(背景、反转、下划线等)。
* 纯 fg 样式获得偶数 ID对空格可见的样式获得奇数 ID。
* 这让渲染器通过打包词上的单个位掩码检查跳过不可见的空格。
*/
intern(styles: AnsiCode[]): number {
const key = styles.length === 0 ? '' : styles.map(s => s.code).join('\0')
let id = this.ids.get(key)
if (id === undefined) {
const rawId = this.styles.length
this.styles.push(styles.length === 0 ? [] : styles)
id =
(rawId << 1) |
(styles.length > 0 && hasVisibleSpaceEffect(styles) ? 1 : 0)
this.ids.set(key, id)
}
return id
}
/** 从编码 ID 恢复样式。通过 >>> 1 剥离第 0 位标志。 */
get(id: number): AnsiCode[] {
return this.styles[id >>> 1] ?? []
}
/**
* 返回从一种样式转换到另一种的预序列化 ANSI 字符串。
* 由 (fromId, toId) 缓存 — 首次调用后给定对零分配。
*/
transition(fromId: number, toId: number): string {
if (fromId === toId) return ''
const key = fromId * 0x100000 + toId
let str = this.transitionCache.get(key)
if (str === undefined) {
str = ansiCodesToString(diffAnsiCodes(this.get(fromId), this.get(toId)))
this.transitionCache.set(key, str)
}
return str
}
/**
* 驻留一个样式是 `base + inverse`。由 base ID 缓存,
* 因此对相同底层样式的重复调用不会重新扫描 AnsiCode[] 数组。
* 用于选择覆盖。
*/
private inverseCache = new Map<number, number>()
withInverse(baseId: number): number {
let id = this.inverseCache.get(baseId)
if (id === undefined) {
const baseCodes = this.get(baseId)
// 如果已经反转,使用原样(避免 SGR 7 堆叠)
const hasInverse = baseCodes.some(c => c.endCode === '\x1b[27m')
id = hasInverse ? baseId : this.intern([...baseCodes, INVERSE_CODE])
this.inverseCache.set(baseId, id)
}
return id
}
/** 当前搜索匹配的 Inverse + bold + yellow-bg-via-fg-swap。
* 其他匹配是纯 inverse — bg 从主题继承。当前
* 获得独特的黄色 bg通过 fg-then-inverse 交换)加上粗体重量
* 使其在 inverse 的海洋中脱颖而出。下划线太微妙。
* 零重排风险:所有纯 SGR 覆盖,逐单元格,布局后。
* 黄色覆盖那些单元格上任何现有的 fg语法高亮— 可以,
* "你在这里"信号就是重点,语法颜色可以让步。*/
private currentMatchCache = new Map<number, number>()
withCurrentMatch(baseId: number): number {
let id = this.currentMatchCache.get(baseId)
if (id === undefined) {
const baseCodes = this.get(baseId)
// 过滤 fg + bg 以便 yellow-via-inverse 清晰。
// 用户提示单元格有明确的 bg灰色框如果那个 bg
// 仍然设置inverse 交换 yellow-fg↔grey-bg →
// 在某些终端上 grey-on-yellow在其他终端上 yellow-on-grey
//当两种颜色都明确时inverse 语义不同)。
// 过滤两者给出清晰的 yellow-bg + 终端默认-fg 到处。
// Bold/dim/italic 共存 — 保留那些。
const codes = baseCodes.filter(
c => c.endCode !== '\x1b[39m' && c.endCode !== '\x1b[49m',
)
// fg-yellow 首先,这样 inverse 交换它到 bg。Bold 在 inverse 之后没问题 —
// SGR 1 仅为 fg 属性,与 7 顺序无关。
codes.push(YELLOW_FG_CODE)
if (!baseCodes.some(c => c.endCode === '\x1b[27m'))
codes.push(INVERSE_CODE)
if (!baseCodes.some(c => c.endCode === '\x1b[22m')) codes.push(BOLD_CODE)
// 下划线作为清晰标记 — yellow-bg 可能与现有
// bg 样式冲突(用户提示 bg、语法 bg
// 如果你在匹配上看到下划线但没有黄色,覆盖确实找到了它;
// 黄色只是在样式战斗中失败。
if (!baseCodes.some(c => c.endCode === '\x1b[24m'))
codes.push(UNDERLINE_CODE)
id = this.intern(codes)
this.currentMatchCache.set(baseId, id)
}
return id
}
/**
* 选择覆盖:用纯色替换单元格的背景,同时保留其前景
*颜色、粗体、斜体、dim、下划线。匹配本机终端选择
* — 专用 bg 颜色,不是 SGR-7 反转。反转交换 fg/bg
* 每单元格,这在语法高亮文本上视觉上碎片化
*(每个 fg 颜色变成不同的 bg 条纹)。
*
* 剥离任何现有的 bgendCode 49m — 替换,所以 diff 添加的绿色
* 等不会渗透和任何现有的反转endCode 27m —
* 在实心 bg 顶部反转会重新交换并看起来错误)。
*
* bg 通过 setSelectionBg() 设置null → 回退到 withInverse() 以便
* 覆盖在主题布线设置颜色之前仍然有效(测试、第一帧)。
* 缓存仅由 baseId 键入 — setSelectionBg() 在更改时清除它。
*/
private selectionBgCode: AnsiCode | null = null
private selectionBgCache = new Map<number, number>()
setSelectionBg(bg: AnsiCode | null): void {
if (this.selectionBgCode?.code === bg?.code) return
this.selectionBgCode = bg
this.selectionBgCache.clear()
}
withSelectionBg(baseId: number): number {
const bg = this.selectionBgCode
if (bg === null) return this.withInverse(baseId)
let id = this.selectionBgCache.get(baseId)
if (id === undefined) {
// 保留除 bg49m和 inverse27m之外的所有内容。
// Fg、bold、dim、italic、underline、strikethrough 全部保留。
const kept = this.get(baseId).filter(
c => c.endCode !== '\x1b[49m' && c.endCode !== '\x1b[27m',
)
kept.push(bg)
id = this.intern(kept)
this.selectionBgCache.set(baseId, id)
}
return id
}
}
// 在空格字符上产生可见效果的 endCode
const VISIBLE_ON_SPACE = new Set([
'\x1b[49m', // 背景颜色
'\x1b[27m', // 反转
'\x1b[24m', // 下划线
'\x1b[29m', // 删除线
'\x1b[55m', // 上划线
])
function hasVisibleSpaceEffect(styles: AnsiCode[]): boolean {
for (const style of styles) {
if (VISIBLE_ON_SPACE.has(style.endCode)) return true
}
return false
}
/**
* 用于处理双宽字符CJK、emoji 等)的单元格宽度分类。
*
* 我们使用显式 spacer 单元格而不是在渲染时推断宽度。这
* 使数据结构自描述并简化光标定位逻辑。
*
* @see https://mitchellh.com/writing/grapheme-clusters-in-terminals
*/
// const enum 在编译时内联 — 无运行时对象,无属性访问
export const enum CellWidth {
// 非宽字符,单元格宽度 1
Narrow = 0,
// 宽字符,单元格宽度 2。此单元格包含实际字符。
Wide = 1,
// 占宽字符第二个视觉列的 spacer。不要渲染。
SpacerTail = 2,
// 软换行行末尾的 spacer指示宽字符
// 在换行期间继续到下一行。用于在软换行期间
// 跨行断点保留宽字符语义。
SpacerHead = 3,
}

View File

@@ -0,0 +1,91 @@
import {
CellWidth,
cellAtIndex,
type Screen,
type StylePool,
setCellStyleId,
} from './screen.js'
/**
* 通过反转单元格样式SGR 7高亮屏幕缓冲区中 `query` 的所有可见出现。
* 渲染后,与 applySelectionOverlay 相同的损坏跟踪机制
* — diff 将高亮单元格作为普通变化拾取LogUpdate 保持纯 diff 引擎。
*
* 大小写不敏感。通过每行构建 col-of-char 映射处理宽字符CJK、emoji
* 当存在宽字符时,第 N 个字符不在第 N 列(每个占用 2 个单元格head + SpacerTail
*
* 这只反转 — 这里没有"当前匹配"逻辑。黄色
* 当前匹配覆盖层由 applyPositionedHighlight
*render-to-screen.ts单独处理它使用从
* 目标消息的 DOM 子树扫描的位置在顶部写入。
*
* 如果有任何匹配被高亮则返回 true损坏门控 — 调用者强制
* 完整帧损坏时为 true
*/
export function applySearchHighlight(
screen: Screen,
query: string,
stylePool: StylePool,
): boolean {
if (!query) return false
const lq = query.toLowerCase()
const qlen = lq.length
const w = screen.width
const noSelect = screen.noSelect
const height = screen.height
let applied = false
for (let row = 0; row < height; row++) {
const rowOff = row * w
// 构建行文本(已经小写)+ code-unit→cell-index 映射。
// 三个跳过条件,全部与 setCellStyleId /
// extractRowTextselection.ts对齐
// - SpacerTail: 宽字符的第二个单元格,没有自己的字符
// - SpacerHead: 宽字符换行时的行尾填充
// - noSelect: 沟槽(⎿, 行号)— 与
// applySelectionOverlay 相同的排除。"高亮你看到的"仍然适用于
// 内容;沟槽不是搜索目标。
// 逐字符小写(不是在最后连接的字符串上)意味着
// codeUnitToCell 映射 LOWERCASED 文本中的位置 — U+0130
//(土耳其语 İ)小写为 2 个码点,所以在连接的
// 字符串上小写会使 indexOf 位置与映射不同步。
let text = ''
const colOf: number[] = []
const codeUnitToCell: number[] = []
for (let col = 0; col < w; col++) {
const idx = rowOff + col
const cell = cellAtIndex(screen, idx)
if (
cell.width === CellWidth.SpacerTail ||
cell.width === CellWidth.SpacerHead ||
noSelect[idx] === 1
) {
continue
}
const lc = cell.char.toLowerCase()
const cellIdx = colOf.length
for (let i = 0; i < lc.length; i++) {
codeUnitToCell.push(cellIdx)
}
text += lc
colOf.push(col)
}
let pos = text.indexOf(lq)
while (pos >= 0) {
applied = true
const startCi = codeUnitToCell[pos]!
const endCi = codeUnitToCell[pos + qlen - 1]!
for (let ci = startCi; ci <= endCi; ci++) {
const col = colOf[ci]!
const cell = cellAtIndex(screen, rowOff + col)
setCellStyleId(screen, col, row, stylePool.withInverse(cell.styleId))
}
// 非重叠前进less/vim/grep/Ctrl+F。pos+1 会在 'aaa' 的 0 和 1 处找到
// 'aa' → 单元格 1 双重反转。
pos = text.indexOf(lq, pos + qlen)
}
}
return applied
}

View File

@@ -0,0 +1,225 @@
/**
* 全屏模式的文本选择状态。
*
* 在屏幕缓冲区坐标中跟踪线性选择0 索引 col/row
* 选择是基于行的:单元格从 (startCol, startRow) 到
* (endCol, endRow) 包含,跨越行边界换行。这匹配
* 终端本机选择行为(不是矩形/块)。
*
* 选择存储为 ANCHOR拖动开始的位置+ FOCUS现在是光标的位置
* 渲染的高亮规范化到 start ≤ end。
*/
import { clamp } from './layout/geometry.js'
import type { Screen, StylePool } from './screen.js'
import { CellWidth, cellAt, cellAtIndex, setCellStyleId } from './screen.js'
type Point = { col: number; row: number }
export type SelectionState = {
/** 鼠标按下发生的位置。无选择时为 null。 */
anchor: Point | null
/** 当前拖动位置(拖动时在鼠标移动上更新)。 */
focus: Point | null
/** 在鼠标按下和鼠标释放之间为 true。 */
isDragging: boolean
/** 对于单词/行模式:第一次多击时的初始单词/行边界。
* 拖动从该跨度扩展到当前鼠标位置的单词/行,
* 以便原始单词/行在向后拖动经过它时仍保持选中。
* Null ⇔ char 模式。kind 告诉 extendSelection 是否
* 吸附到单词或行边界。*/
anchorSpan: { lo: Point; hi: Point; kind: 'word' | 'line' } | null
/** 在拖动滚动期间滚动到视口上方的行。
* 屏幕缓冲区仅保存当前视口,
* 所以没有这个累加器,向下拖动超过底部边缘会
* 一旦 anchor 夹紧就失去选择的顶部。
* 由 getSelectedText 预先考虑屏幕外文本。开始/清除时重置。*/
scrolledOffAbove: string[]
/** 对称的:向上拖动时向下滚出的行。附加。*/
scrolledOffBelow: string[]
/** 与 scrolledOffAbove 平行的软换行位 — true 意味着该行
* 是前一行(`\n` 是由
* 词换行插入的,不是源中的)的延续。
* 在滚动时间与文本一起捕获,因为屏幕的 softWrap 位图随内容移动。
* getSelectedText 使用这些将包装的行连接回逻辑行。*/
scrolledOffAboveSW: boolean[]
/** 与 scrolledOffBelow 平行。*/
scrolledOffBelowSW: boolean[]
/** 预夹紧 anchor 行。在 shiftSelection 夹紧 anchor 时设置,
* 以便反向滚动可以恢复真实位置并弹出累加器。
* 没有这个PgDn夹紧 anchor→ PgUp 在错误的行上留下 anchor
* AND scrolledOffAbove 陈旧 — 高亮 ≠ 复制。
* 当 anchor 在范围内时未定义(无夹紧债务)。开始/清除时清除。*/
virtualAnchorRow?: number
/** 对 focus 相同。*/
virtualFocusRow?: number
/** 如果开始此选择的鼠标按下设置了 alt
* 修饰符SGR 按钮位 0x08为 true。在 macOS xterm.js 上这是
* VS Code 的 macOptionClickForcesSelection 为 OFF 的信号 —
* 如果是xterm.js 会为原生选择消耗事件,
* 我们永远不会收到它。由页脚用于显示正确的提示。*/
lastPressHadAlt: boolean
}
export function createSelectionState(): SelectionState {
return {
anchor: null,
focus: null,
isDragging: false,
anchorSpan: null,
scrolledOffAbove: [],
scrolledOffBelow: [],
scrolledOffAboveSW: [],
scrolledOffBelowSW: [],
lastPressHadAlt: false,
}
}
export function startSelection(
s: SelectionState,
col: number,
row: number,
): void {
s.anchor = { col, row }
// Focus 在第一次拖动动作之前不设置。没有
// 拖动的点击释放让 focus 保持 null →
// hasSelection/selectionBounds 通过 `!s.focus` 检查返回 false/null
// 所以裸点击永远不会高亮单元格。
s.focus = null
s.isDragging = true
s.anchorSpan = null
s.scrolledOffAbove = []
s.scrolledOffBelow = []
s.scrolledOffAboveSW = []
s.scrolledOffBelowSW = []
s.virtualAnchorRow = undefined
s.virtualFocusRow = undefined
s.lastPressHadAlt = false
}
export function updateSelection(
s: SelectionState,
col: number,
row: number,
): void {
if (!s.isDragging) return
// 在与 anchor 相同的单元格上的第一次动作是无操作。模式
// 1002 中的终端可以触发拖动事件在 anchor 单元格(亚像素颤抖,或
// 动作/释放对)。在这里设置 focus 会将裸点击变成
// 1 单元格选择,并通过 useCopyOnSelect 弄乱剪贴板。一旦
// focus 被设置(真实拖动),我们正常跟踪包括回到 anchor。
if (!s.focus && s.anchor && s.anchor.col === col && s.anchor.row === row)
return
s.focus = { col, row }
}
export function finishSelection(s: SelectionState): void {
s.isDragging = false
// 保持 anchor/focus 以便高亮保持可见,文本可以被复制。
// 在 Esc 或复制后通过 clearSelection() 清除。
}
export function clearSelection(s: SelectionState): void {
s.anchor = null
s.focus = null
s.isDragging = false
s.anchorSpan = null
s.scrolledOffAbove = []
s.scrolledOffBelow = []
s.scrolledOffAboveSW = []
s.scrolledOffBelowSW = []
s.virtualAnchorRow = undefined
s.virtualFocusRow = undefined
s.lastPressHadAlt = false
}
// Unicode 感知的单词字符匹配器:字母(任何脚本)、数字,
// 以及 iTerm2 默认视为单词部分的标点符号集。
// 匹配 iTerm2 的默认值意味着双击路径如
// `/usr/bin/bash` 或 `~/.claude/config.json` 选择整个东西,
// 这是大多数 macOS 终端用户拥有的肌肉记忆。
// iTerm2 默认"视为单词一部分的字符"/-+\~_.
const WORD_CHAR = /[\p{L}\p{N}_/.\-+~\\]/u
/**
* 双击单词扩展的字符类。具有与点击单元格相同类的
* 单元格包含在选择中类更改是边界。匹配典型终端模拟器行为iTerm2 等):
* 双击 `foo` 选择 `foo`,双击 `->` 选择 `->`,双击空格
* 选择空格运行。
*/
function charClass(c: string): 0 | 1 | 2 {
if (c === ' ' || c === '') return 0
if (WORD_CHAR.test(c)) return 1
return 2
}
/**
* 找到 (col, row) 处相同类字符运行的边界。如果点击超出范围或落在
* noSelect 单元格上则返回 null。由 selectWordAt初始双击
* extendWordSelection拖动使用。
*/
function wordBoundsAt(
screen: Screen,
col: number,
row: number,
): { lo: number; hi: number } | null {
if (row < 0 || row >= screen.height) return null
const width = screen.width
const noSelect = screen.noSelect
const rowOff = row * width
// 如果点击落在宽字符的 spacer tail 上,步回
// 到 head 以便类检查看到实际的字素。
let c = col
if (c > 0) {
const cell = cellAt(screen, c, row)
if (cell && cell.width === CellWidth.SpacerTail) c -= 1
}
if (c < 0 || c >= width || noSelect[rowOff + c] === 1) return null
const startCell = cellAt(screen, c, row)
if (!startCell) return null
const cls = charClass(startCell.char)
// 向左扩展:包含相同类的单元格,在 noSelect 或
// 类更改时停止。SpacerTail 单元格被跳过(宽字符 head
// 在前一列决定类)。
let lo = c
while (lo > 0) {
const prev = lo - 1
if (noSelect[rowOff + prev] === 1) break
const pc = cellAt(screen, prev, row)
if (!pc) break
if (pc.width === CellWidth.SpacerTail) {
// 跳过 spacer 到宽字符 head
if (prev === 0 || noSelect[rowOff + prev - 1] === 1) break
const head = cellAt(screen, prev - 1, row)
if (!head || charClass(head.char) !== cls) break
lo = prev - 1
continue
}
if (charClass(pc.char) !== cls) break
lo = prev
}
// 向右扩展
let hi = c
while (hi < width - 1) {
const next = hi + 1
if (noSelect[rowOff + next] === 1) break
const nc = cellAt(screen, next, row)
if (!nc) break
if (nc.width === CellWidth.SpacerTail) {
// 跳过 spacer 到宽字符 head
if (next === width - 1 || noSelect[rowOff + next + 1] === 1) break
const head = cellAt(screen, next + 1, row)
if (!head || charClass(head.char) !== cls) break
hi = next + 1
continue
}
if (charClass(nc.char) !== cls) break
hi = next
}
return { lo, hi }
}

View File

@@ -0,0 +1,92 @@
import type { DOMElement } from './dom.js'
import type { TextStyles } from './styles.js'
/**
* 具有关联样式的文本段。
* 用于不依赖 ANSI 字符串转换的结构化渲染。
*/
export type StyledSegment = {
text: string
styles: TextStyles
hyperlink?: string
}
/**
* 将文本节点压缩为样式段,沿着树向下传播样式。
* 这允许结构化样式而不依赖 ANSI 字符串转换。
*/
export function squashTextNodesToSegments(
node: DOMElement,
inheritedStyles: TextStyles = {},
inheritedHyperlink?: string,
out: StyledSegment[] = [],
): StyledSegment[] {
const mergedStyles = node.textStyles
? { ...inheritedStyles, ...node.textStyles }
: inheritedStyles
for (const childNode of node.childNodes) {
if (childNode === undefined) {
continue
}
if (childNode.nodeName === '#text') {
if (childNode.nodeValue.length > 0) {
out.push({
text: childNode.nodeValue,
styles: mergedStyles,
hyperlink: inheritedHyperlink,
})
}
} else if (
childNode.nodeName === 'ink-text' ||
childNode.nodeName === 'ink-virtual-text'
) {
squashTextNodesToSegments(
childNode,
mergedStyles,
inheritedHyperlink,
out,
)
} else if (childNode.nodeName === 'ink-link') {
const href = childNode.attributes['href'] as string | undefined
squashTextNodesToSegments(
childNode,
mergedStyles,
href || inheritedHyperlink,
out,
)
}
}
return out
}
/**
* 将文本节点压缩为纯字符串(无样式)。
* 用于布局计算中的文本测量。
*/
function squashTextNodes(node: DOMElement): string {
let text = ''
for (const childNode of node.childNodes) {
if (childNode === undefined) {
continue
}
if (childNode.nodeName === '#text') {
text += childNode.nodeValue
} else if (
childNode.nodeName === 'ink-text' ||
childNode.nodeName === 'ink-virtual-text'
) {
text += squashTextNodes(childNode)
} else if (childNode.nodeName === 'ink-link') {
text += squashTextNodes(childNode)
}
}
return text
}
export default squashTextNodes

View File

@@ -0,0 +1,228 @@
import emojiRegex from 'emoji-regex'
import { eastAsianWidth } from 'get-east-asian-width'
import stripAnsi from 'strip-ansi'
import { getGraphemeSegmenter } from '../utils/intl.js'
const EMOJI_REGEX = emojiRegex()
/**
* 当 Bun.stringWidth 不可用时的 JavaScript 回退实现。
*
* 获取字符串在终端中显示的宽度。
*
* 这是 string-width 包的更准确替代方案,
* 可以正确处理 string-width 错误地报告为宽度 2 的 ⚠ (U+26A0) 等字符。
*
* 实现直接使用 eastAsianWidthambiguousAsWide: false
* 根据 Unicode 标准对西方语境的建议,
* 正确地将模糊宽度字符视为窄(宽度 1
*/
function stringWidthJavaScript(str: string): number {
if (typeof str !== 'string' || str.length === 0) {
return 0
}
// 快速路径:纯 ASCII 字符串(无 ANSI 代码,无宽字符)
let isPureAscii = true
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i)
// 检查非 ASCII 或 ANSI 转义 (0x1b)
if (code >= 127 || code === 0x1b) {
isPureAscii = false
break
}
}
if (isPureAscii) {
// 计算可打印字符数(排除控制字符)
let width = 0
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i)
if (code > 0x1f) {
width++
}
}
return width
}
// 如果存在转义字符则剥离 ANSI
if (str.includes('\x1b')) {
str = stripAnsi(str)
if (str.length === 0) {
return 0
}
}
// 快速路径:简单 Unicode无 emoji、变体选择符或连接符
if (!needsSegmentation(str)) {
let width = 0
for (const char of str) {
const codePoint = char.codePointAt(0)!
if (!isZeroWidth(codePoint)) {
width += eastAsianWidth(codePoint, { ambiguousAsWide: false })
}
}
return width
}
let width = 0
for (const { segment: grapheme } of getGraphemeSegmenter().segment(str)) {
// 首先检查 emoji大多数 emoji 序列宽度为 2
EMOJI_REGEX.lastIndex = 0
if (EMOJI_REGEX.test(grapheme)) {
width += getEmojiWidth(grapheme)
continue
}
// 计算非 emoji 字素簇的宽度
// 对于字素簇(如带有 virama+ZWJ 的天城文辅音),只计算
// 第一个非零宽度字符的宽度,因为该簇渲染为一个字形
for (const char of grapheme) {
const codePoint = char.codePointAt(0)!
if (!isZeroWidth(codePoint)) {
width += eastAsianWidth(codePoint, { ambiguousAsWide: false })
break
}
}
}
return width
}
function needsSegmentation(str: string): boolean {
for (const char of str) {
const cp = char.codePointAt(0)!
// Emoji 范围
if (cp >= 0x1f300 && cp <= 0x1faff) return true
if (cp >= 0x2600 && cp <= 0x27bf) return true
if (cp >= 0x1f1e6 && cp <= 0x1f1ff) return true
// 变体选择符、ZWJ
if (cp >= 0xfe00 && cp <= 0xfe0f) return true
if (cp === 0x200d) return true
}
return false
}
function getEmojiWidth(grapheme: string): number {
// 区域指示符:单个 = 1成对 = 2
const first = grapheme.codePointAt(0)!
if (first >= 0x1f1e6 && first <= 0x1f1ff) {
let count = 0
for (const _ of grapheme) count++
return count === 1 ? 1 : 2
}
// 不完整的键帽:数字/符号 + VS16 但没有 U+20E3
if (grapheme.length === 2) {
const second = grapheme.codePointAt(1)
if (
second === 0xfe0f &&
((first >= 0x30 && first <= 0x39) || first === 0x23 || first === 0x2a)
) {
return 1
}
}
return 2
}
/**
* 检查代码点是否为零宽度字符。
*/
function isZeroWidth(codePoint: number): boolean {
// 常见可打印范围的快速路径
if (codePoint >= 0x20 && codePoint < 0x7f) return false
if (codePoint >= 0xa0 && codePoint < 0x0300) return codePoint === 0x00ad
// 控制字符
if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) return true
// 零宽度和不可见字符
if (
(codePoint >= 0x200b && codePoint <= 0x200d) || // ZW space/joiner
codePoint === 0xfeff || // BOM
(codePoint >= 0x2060 && codePoint <= 0x2064) // Word joiner etc.
) {
return true
}
// 变体选择符
if (
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
(codePoint >= 0xe0100 && codePoint <= 0xe01ef)
) {
return true
}
// 组合变音符号
if (
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
(codePoint >= 0xfe20 && codePoint <= 0xfe2f)
) {
return true
}
// 梵文脚本组合符号(覆盖天城文到马拉雅拉姆文)
if (codePoint >= 0x0900 && codePoint <= 0x0d4f) {
// 每个脚本块开头的符号和元音符号
const offset = codePoint & 0x7f
if (offset <= 0x03) return true // 块开头的符号
if (offset >= 0x3a && offset <= 0x4f) return true // 元音符号、virama
if (offset >= 0x51 && offset <= 0x57) return true // 重音符号
if (offset >= 0x62 && offset <= 0x63) return true // 元音符号
}
// 泰文/老挝文组合符号
// 注意U+0E32 (SARA AA)、U+0E33 (SARA AM)、U+0EB2、U+0EB3 是间距元音(宽度 1不是组合符号
if (
codePoint === 0x0e31 || // 泰文 MAI HAN-AKAT
(codePoint >= 0x0e34 && codePoint <= 0x0e3a) || // 泰文元音符号(跳过 U+0E32、U+0E33
(codePoint >= 0x0e47 && codePoint <= 0x0e4e) || // 泰文元音符号和标记
codePoint === 0x0eb1 || // 老挝文 MAI KAN
(codePoint >= 0x0eb4 && codePoint <= 0x0ebc) || // 老挝文元音符号(跳过 U+0EB2、U+0EB3
(codePoint >= 0x0ec8 && codePoint <= 0x0ecd) // 老挝文声调标记
) {
return true
}
// 阿拉伯文格式
if (
(codePoint >= 0x0600 && codePoint <= 0x0605) ||
codePoint === 0x06dd ||
codePoint === 0x070f ||
codePoint === 0x08e2
) {
return true
}
// 代理对、标记字符
if (codePoint >= 0xd800 && codePoint <= 0xdfff) return true
if (codePoint >= 0xe0000 && codePoint <= 0xe007f) return true
return false
}
// 注意:像天城文 क्ष (ka+virama+ZWJ+ssa) 这样的复杂脚本字素簇渲染为
// 单个连字字形,但占据 2 个终端单元wcwidth 总和基础辅音)。
// Bun.stringWidth=2 匹配终端单元分配,这是我们进行光标定位所需的 ——
// JS 回退的字素簇宽度 1 会使 Ink 的布局与终端不同步。
//
// Bun.stringWidth 在模块范围解析一次,而不是在每次调用时检查 ——
// typeof guard 会使属性访问反优化,而这是一条热路径(~100k 调用/帧)。
const bunStringWidth =
typeof Bun !== 'undefined' && typeof Bun.stringWidth === 'function'
? Bun.stringWidth
: null
const BUN_STRING_WIDTH_OPTS = { ambiguousIsNarrow: true } as const
/**
* 计算字符串在终端中的显示宽度。
* 优先使用 Bun 的高性能实现,回退到 JavaScript 实现。
*/
export const stringWidth: (str: string) => number = bunStringWidth
? str => bunStringWidth(str, BUN_STRING_WIDTH_OPTS)
: stringWidthJavaScript

View File

@@ -0,0 +1,771 @@
import {
LayoutAlign,
LayoutDisplay,
LayoutEdge,
LayoutFlexDirection,
LayoutGutter,
LayoutJustify,
type LayoutNode,
LayoutOverflow,
LayoutPositionType,
LayoutWrap,
} from './layout/node.js'
import type { BorderStyle, BorderTextOptions } from './render-border.js'
export type RGBColor = `rgb(${number},${number},${number})`
export type HexColor = `#${string}`
export type Ansi256Color = `ansi256(${number})`
export type AnsiColor =
| 'ansi:black'
| 'ansi:red'
| 'ansi:green'
| 'ansi:yellow'
| 'ansi:blue'
| 'ansi:magenta'
| 'ansi:cyan'
| 'ansi:white'
| 'ansi:blackBright'
| 'ansi:redBright'
| 'ansi:greenBright'
| 'ansi:yellowBright'
| 'ansi:blueBright'
| 'ansi:magentaBright'
| 'ansi:cyanBright'
| 'ansi:whiteBright'
/** 原始颜色值 - 不是主题键 */
export type Color = RGBColor | HexColor | Ansi256Color | AnsiColor
/**
* 结构化文本样式属性。
* 用于样式化文本而不依赖 ANSI 字符串转换。
* 颜色是原始值 - 主题解析在组件层完成。
*/
export type TextStyles = {
readonly color?: Color
readonly backgroundColor?: Color
readonly dim?: boolean
readonly bold?: boolean
readonly italic?: boolean
readonly underline?: boolean
readonly strikethrough?: boolean
readonly inverse?: boolean
}
export type Styles = {
readonly textWrap?:
| 'wrap'
| 'wrap-trim'
| 'end'
| 'middle'
| 'truncate-end'
| 'truncate'
| 'truncate-middle'
| 'truncate-start'
readonly position?: 'absolute' | 'relative'
readonly top?: number | `${number}%`
readonly bottom?: number | `${number}%`
readonly left?: number | `${number}%`
readonly right?: number | `${number}%`
/**
* 元素列之间的间隙大小。
*/
readonly columnGap?: number
/**
* 元素行之间的间隙大小。
*/
readonly rowGap?: number
/**
* 元素列和行之间的间隙大小。是 `columnGap` 和 `rowGap` 的简写。
*/
readonly gap?: number
/**
* 四边外边距。相当于设置 `marginTop`、`marginBottom`、`marginLeft` 和 `marginRight`。
*/
readonly margin?: number
/**
* 水平外边距。相当于设置 `marginLeft` 和 `marginRight`。
*/
readonly marginX?: number
/**
* 垂直外边距。相当于设置 `marginTop` 和 `marginBottom`。
*/
readonly marginY?: number
/**
* 上外边距。
*/
readonly marginTop?: number
/**
* 下外边距。
*/
readonly marginBottom?: number
/**
* 左外边距。
*/
readonly marginLeft?: number
/**
* 右外边距。
*/
readonly marginRight?: number
/**
* 四边内边距。相当于设置 `paddingTop`、`paddingBottom`、`paddingLeft` 和 `paddingRight`。
*/
readonly padding?: number
/**
* 水平内边距。相当于设置 `paddingLeft` 和 `paddingRight`。
*/
readonly paddingX?: number
/**
* 垂直内边距。相当于设置 `paddingTop` 和 `paddingBottom`。
*/
readonly paddingY?: number
/**
* 上内边距。
*/
readonly paddingTop?: number
/**
* 下内边距。
*/
readonly paddingBottom?: number
/**
* 左内边距。
*/
readonly paddingLeft?: number
/**
* 右内边距。
*/
readonly paddingRight?: number
/**
* 定义 flex 项在必要时增长的能力。
* 参见 [flex-grow](https://css-tricks.com/almanac/properties/f/flex-grow/)。
*/
readonly flexGrow?: number
/**
* 指定"flex 收缩因子",它决定 flex 项在 flex 容器中空间不足时相对于其他 flex 项收缩多少。
* 参见 [flex-shrink](https://css-tricks.com/almanac/properties/f/flex-shrink/)。
*/
readonly flexShrink?: number
/**
* 建立主轴,从而定义 flex 项放置在 flex 容器中的方向。
* 参见 [flex-direction](https://css-tricks.com/almanac/properties/f/flex-direction/)。
*/
readonly flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse'
/**
* 指定 flex 项的初始大小,在根据 flex 因子分配可用空间之前。
* 参见 [flex-basis](https://css-tricks.com/almanac/properties/f/flex-basis/)。
*/
readonly flexBasis?: number | string
/**
* 定义 flex 项是被强制成单行还是可以流式排列成多行。如果设置为多行,
* 还定义了决定新行堆叠方向的交叉轴。
* 参见 [flex-wrap](https://css-tricks.com/almanac/properties/f/flex-wrap/)。
*/
readonly flexWrap?: 'nowrap' | 'wrap' | 'wrap-reverse'
/**
* align-items 属性定义 items 沿着横轴(垂直于主轴)对齐的默认行为。
* 参见 [align-items](https://css-tricks.com/almanac/properties/a/align-items/)。
*/
readonly alignItems?: 'flex-start' | 'center' | 'flex-end' | 'stretch'
/**
* 它使得覆盖特定 flex 项的 align-items 值成为可能。
* 参见 [align-self](https://css-tricks.com/almanac/properties/a/align-self/)。
*/
readonly alignSelf?: 'flex-start' | 'center' | 'flex-end' | 'auto'
/**
* 定义沿着主轴的对齐方式。
* 参见 [justify-content](https://css-tricks.com/almanac/properties/j/justify-content/)。
*/
readonly justifyContent?:
| 'flex-start'
| 'flex-end'
| 'space-between'
| 'space-around'
| 'space-evenly'
| 'center'
/**
* 元素的宽度(以空格为单位)。
* 也可以设置为百分比,这将基于父元素的宽度计算宽度。
*/
readonly width?: number | string
/**
* 元素的高度(以行(行)为单位)。
* 也可以设置为百分比,这将基于父元素的高度计算高度。
*/
readonly height?: number | string
/**
* 设置元素的最小宽度。
*/
readonly minWidth?: number | string
/**
* 设置元素的最小高度。
*/
readonly minHeight?: number | string
/**
* 设置元素的最大宽度。
*/
readonly maxWidth?: number | string
/**
* 设置元素的最大高度。
*/
readonly maxHeight?: number | string
/**
* 将此属性设置为 `none` 以隐藏元素。
*/
readonly display?: 'flex' | 'none'
/**
* 添加具有指定样式的边框。
* 如果 `borderStyle` 是 `undefined`(默认情况),则不会添加边框。
*/
readonly borderStyle?: BorderStyle
/**
* 确定顶部边框是否可见。
*
* @default true
*/
readonly borderTop?: boolean
/**
* 确定底部边框是否可见。
*
* @default true
*/
readonly borderBottom?: boolean
/**
* 确定左边框是否可见。
*
* @default true
*/
readonly borderLeft?: boolean
/**
* 确定右边框是否可见。
*
* @default true
*/
readonly borderRight?: boolean
/**
* 更改边框颜色。
* 设置 `borderTopColor`、`borderRightColor`、`borderBottomColor` 和 `borderLeftColor` 的简写。
*/
readonly borderColor?: Color
/**
* 更改顶部边框颜色。
* 接受原始颜色值rgb、hex、ansi
*/
readonly borderTopColor?: Color
/**
* 更改底部边框颜色。
* 接受原始颜色值rgb、hex、ansi
*/
readonly borderBottomColor?: Color
/**
* 更改左边框颜色。
* 接受原始颜色值rgb、hex、ansi
*/
readonly borderLeftColor?: Color
/**
* 更改右边框颜色。
* 接受原始颜色值rgb、hex、ansi
*/
readonly borderRightColor?: Color
/**
* 使边框颜色变暗。
* 设置 `borderTopDimColor`、`borderBottomDimColor`、`borderLeftDimColor` 和 `borderRightDimColor` 的简写。
*
* @default false
*/
readonly borderDimColor?: boolean
/**
* 使顶部边框颜色变暗。
*
* @default false
*/
readonly borderTopDimColor?: boolean
/**
* 使底部边框颜色变暗。
*
* @default false
*/
readonly borderBottomDimColor?: boolean
/**
* 使左边框颜色变暗。
*
* @default false
*/
readonly borderLeftDimColor?: boolean
/**
* 使右边框颜色变暗。
*
* @default false
*/
readonly borderRightDimColor?: boolean
/**
* 在边框内添加文本。仅适用于顶部或底部边框。
*/
readonly borderText?: BorderTextOptions
/**
* 框的背景下颜色。用背景色空格填充内部,
* 并作为其子文本节点的默认背景继承。
*/
readonly backgroundColor?: Color
/**
* 在渲染子元素之前用空格填充框的内部(包括 padding
* 这样它后面的东西就不会透过来。类似于
* `backgroundColor` 但不发出任何 SGR — 终端的
* 默认背景被使用。这对于绝对定位的覆盖层很有用,
* 否则 Box padding/gaps 会是透明的。
*/
readonly opaque?: boolean
/**
* 元素在两个方向上的溢出行为。
* 'scroll' 约束容器的大小(子元素不会扩展它)
* 并在渲染时启用基于 scrollTop 的虚拟滚动。
*
* @default 'visible'
*/
readonly overflow?: 'visible' | 'hidden' | 'scroll'
/**
* 元素在水平方向上的溢出行为。
*
* @default 'visible'
*/
readonly overflowX?: 'visible' | 'hidden' | 'scroll'
/**
* 元素在垂直方向上的溢出行为。
*
* @default 'visible'
*/
readonly overflowY?: 'visible' | 'hidden' | 'scroll'
/**
* 在全屏模式下将此框的单元格从文本选择中排除。
* 此区域内的单元格被选择高亮和复制的文本跳过 —
* 用于隔离行号、差异标记等,这样在差异上拖动
* 不会产生可复制的干净代码。
* 仅影响 alt-screen 文本选择;否则无操作。
*
* `'from-left-edge'` 将排除范围从第 0 列扩展到框的
* 右边缘,覆盖任何上游缩进(工具消息前缀、树线),
* 这样多行拖动不会从中间行拾取前导空白。
*/
readonly noSelect?: boolean | 'from-left-edge'
}
const applyPositionStyles = (node: LayoutNode, style: Styles): void => {
if ('position' in style) {
node.setPositionType(
style.position === 'absolute'
? LayoutPositionType.Absolute
: LayoutPositionType.Relative,
)
}
if ('top' in style) applyPositionEdge(node, 'top', style.top)
if ('bottom' in style) applyPositionEdge(node, 'bottom', style.bottom)
if ('left' in style) applyPositionEdge(node, 'left', style.left)
if ('right' in style) applyPositionEdge(node, 'right', style.right)
}
function applyPositionEdge(
node: LayoutNode,
edge: 'top' | 'bottom' | 'left' | 'right',
v: number | `${number}%` | undefined,
): void {
if (typeof v === 'string') {
node.setPositionPercent(edge, Number.parseInt(v, 10))
} else if (typeof v === 'number') {
node.setPosition(edge, v)
} else {
node.setPosition(edge, Number.NaN)
}
}
const applyOverflowStyles = (node: LayoutNode, style: Styles): void => {
// Yoga 的 Overflow 控制子元素是否扩展容器。
// 'hidden' 和 'scroll' 都阻止扩展;'scroll' 额外
// 信号表示渲染器应该应用 scrollTop 转换。
// overflowX/Y 是渲染时关注点;对于布局我们使用联合。
const y = style.overflowY ?? style.overflow
const x = style.overflowX ?? style.overflow
if (y === 'scroll' || x === 'scroll') {
node.setOverflow(LayoutOverflow.Scroll)
} else if (y === 'hidden' || x === 'hidden') {
node.setOverflow(LayoutOverflow.Hidden)
} else if (
'overflow' in style ||
'overflowX' in style ||
'overflowY' in style
) {
node.setOverflow(LayoutOverflow.Visible)
}
}
const applyMarginStyles = (node: LayoutNode, style: Styles): void => {
if ('margin' in style) {
node.setMargin(LayoutEdge.All, style.margin ?? 0)
}
if ('marginX' in style) {
node.setMargin(LayoutEdge.Horizontal, style.marginX ?? 0)
}
if ('marginY' in style) {
node.setMargin(LayoutEdge.Vertical, style.marginY ?? 0)
}
if ('marginLeft' in style) {
node.setMargin(LayoutEdge.Start, style.marginLeft || 0)
}
if ('marginRight' in style) {
node.setMargin(LayoutEdge.End, style.marginRight || 0)
}
if ('marginTop' in style) {
node.setMargin(LayoutEdge.Top, style.marginTop || 0)
}
if ('marginBottom' in style) {
node.setMargin(LayoutEdge.Bottom, style.marginBottom || 0)
}
}
const applyPaddingStyles = (node: LayoutNode, style: Styles): void => {
if ('padding' in style) {
node.setPadding(LayoutEdge.All, style.padding ?? 0)
}
if ('paddingX' in style) {
node.setPadding(LayoutEdge.Horizontal, style.paddingX ?? 0)
}
if ('paddingY' in style) {
node.setPadding(LayoutEdge.Vertical, style.paddingY ?? 0)
}
if ('paddingLeft' in style) {
node.setPadding(LayoutEdge.Left, style.paddingLeft || 0)
}
if ('paddingRight' in style) {
node.setPadding(LayoutEdge.Right, style.paddingRight || 0)
}
if ('paddingTop' in style) {
node.setPadding(LayoutEdge.Top, style.paddingTop || 0)
}
if ('paddingBottom' in style) {
node.setPadding(LayoutEdge.Bottom, style.paddingBottom || 0)
}
}
const applyFlexStyles = (node: LayoutNode, style: Styles): void => {
if ('flexGrow' in style) {
node.setFlexGrow(style.flexGrow ?? 0)
}
if ('flexShrink' in style) {
node.setFlexShrink(
typeof style.flexShrink === 'number' ? style.flexShrink : 1,
)
}
if ('flexWrap' in style) {
if (style.flexWrap === 'nowrap') {
node.setFlexWrap(LayoutWrap.NoWrap)
}
if (style.flexWrap === 'wrap') {
node.setFlexWrap(LayoutWrap.Wrap)
}
if (style.flexWrap === 'wrap-reverse') {
node.setFlexWrap(LayoutWrap.WrapReverse)
}
}
if ('flexDirection' in style) {
if (style.flexDirection === 'row') {
node.setFlexDirection(LayoutFlexDirection.Row)
}
if (style.flexDirection === 'row-reverse') {
node.setFlexDirection(LayoutFlexDirection.RowReverse)
}
if (style.flexDirection === 'column') {
node.setFlexDirection(LayoutFlexDirection.Column)
}
if (style.flexDirection === 'column-reverse') {
node.setFlexDirection(LayoutFlexDirection.ColumnReverse)
}
}
if ('flexBasis' in style) {
if (typeof style.flexBasis === 'number') {
node.setFlexBasis(style.flexBasis)
} else if (typeof style.flexBasis === 'string') {
node.setFlexBasisPercent(Number.parseInt(style.flexBasis, 10))
} else {
node.setFlexBasis(Number.NaN)
}
}
if ('alignItems' in style) {
if (style.alignItems === 'stretch' || !style.alignItems) {
node.setAlignItems(LayoutAlign.Stretch)
}
if (style.alignItems === 'flex-start') {
node.setAlignItems(LayoutAlign.FlexStart)
}
if (style.alignItems === 'center') {
node.setAlignItems(LayoutAlign.Center)
}
if (style.alignItems === 'flex-end') {
node.setAlignItems(LayoutAlign.FlexEnd)
}
}
if ('alignSelf' in style) {
if (style.alignSelf === 'auto' || !style.alignSelf) {
node.setAlignSelf(LayoutAlign.Auto)
}
if (style.alignSelf === 'flex-start') {
node.setAlignSelf(LayoutAlign.FlexStart)
}
if (style.alignSelf === 'center') {
node.setAlignSelf(LayoutAlign.Center)
}
if (style.alignSelf === 'flex-end') {
node.setAlignSelf(LayoutAlign.FlexEnd)
}
}
if ('justifyContent' in style) {
if (style.justifyContent === 'flex-start' || !style.justifyContent) {
node.setJustifyContent(LayoutJustify.FlexStart)
}
if (style.justifyContent === 'center') {
node.setJustifyContent(LayoutJustify.Center)
}
if (style.justifyContent === 'flex-end') {
node.setJustifyContent(LayoutJustify.FlexEnd)
}
if (style.justifyContent === 'space-between') {
node.setJustifyContent(LayoutJustify.SpaceBetween)
}
if (style.justifyContent === 'space-around') {
node.setJustifyContent(LayoutJustify.SpaceAround)
}
if (style.justifyContent === 'space-evenly') {
node.setJustifyContent(LayoutJustify.SpaceEvenly)
}
}
}
const applyDimensionStyles = (node: LayoutNode, style: Styles): void => {
if ('width' in style) {
if (typeof style.width === 'number') {
node.setWidth(style.width)
} else if (typeof style.width === 'string') {
node.setWidthPercent(Number.parseInt(style.width, 10))
} else {
node.setWidthAuto()
}
}
if ('height' in style) {
if (typeof style.height === 'number') {
node.setHeight(style.height)
} else if (typeof style.height === 'string') {
node.setHeightPercent(Number.parseInt(style.height, 10))
} else {
node.setHeightAuto()
}
}
if ('minWidth' in style) {
if (typeof style.minWidth === 'string') {
node.setMinWidthPercent(Number.parseInt(style.minWidth, 10))
} else {
node.setMinWidth(style.minWidth ?? 0)
}
}
if ('minHeight' in style) {
if (typeof style.minHeight === 'string') {
node.setMinHeightPercent(Number.parseInt(style.minHeight, 10))
} else {
node.setMinHeight(style.minHeight ?? 0)
}
}
if ('maxWidth' in style) {
if (typeof style.maxWidth === 'string') {
node.setMaxWidthPercent(Number.parseInt(style.maxWidth, 10))
} else {
node.setMaxWidth(style.maxWidth ?? 0)
}
}
if ('maxHeight' in style) {
if (typeof style.maxHeight === 'string') {
node.setMaxHeightPercent(Number.parseInt(style.maxHeight, 10))
} else {
node.setMaxHeight(style.maxHeight ?? 0)
}
}
}
const applyDisplayStyles = (node: LayoutNode, style: Styles): void => {
if ('display' in style) {
node.setDisplay(
style.display === 'flex' ? LayoutDisplay.Flex : LayoutDisplay.None,
)
}
}
const applyBorderStyles = (
node: LayoutNode,
style: Styles,
resolvedStyle?: Styles,
): void => {
// resolvedStyle 是完整的当前样式(已设置在 DOM 节点上)。
// style 可能是一个仅包含更改属性的 diff。对于边框侧面属性
// 我们需要解析后的值,因为 `borderStyle` 在 diff 中可能不包含
// 未更改的边框侧面值(例如 borderTop 保持为 false 但不在 diff 中)。
const resolved = resolvedStyle ?? style
if ('borderStyle' in style) {
const borderWidth = style.borderStyle ? 1 : 0
node.setBorder(
LayoutEdge.Top,
resolved.borderTop !== false ? borderWidth : 0,
)
node.setBorder(
LayoutEdge.Bottom,
resolved.borderBottom !== false ? borderWidth : 0,
)
node.setBorder(
LayoutEdge.Left,
resolved.borderLeft !== false ? borderWidth : 0,
)
node.setBorder(
LayoutEdge.Right,
resolved.borderRight !== false ? borderWidth : 0,
)
} else {
// 处理单独的边框属性更改(当仅 borderX 更改而没有 borderStyle 时)。
// 跳过 undefined 值 — 它们意味着该 prop 被移除或从未设置,
// 而不是应该启用边框。
if ('borderTop' in style && style.borderTop !== undefined) {
node.setBorder(LayoutEdge.Top, style.borderTop === false ? 0 : 1)
}
if ('borderBottom' in style && style.borderBottom !== undefined) {
node.setBorder(LayoutEdge.Bottom, style.borderBottom === false ? 0 : 1)
}
if ('borderLeft' in style && style.borderLeft !== undefined) {
node.setBorder(LayoutEdge.Left, style.borderLeft === false ? 0 : 1)
}
if ('borderRight' in style && style.borderRight !== undefined) {
node.setBorder(LayoutEdge.Right, style.borderRight === false ? 0 : 1)
}
}
}
const applyGapStyles = (node: LayoutNode, style: Styles): void => {
if ('gap' in style) {
node.setGap(LayoutGutter.All, style.gap ?? 0)
}
if ('columnGap' in style) {
node.setGap(LayoutGutter.Column, style.columnGap ?? 0)
}
if ('rowGap' in style) {
node.setGap(LayoutGutter.Row, style.rowGap ?? 0)
}
}
const styles = (
node: LayoutNode,
style: Styles = {},
resolvedStyle?: Styles,
): void => {
applyPositionStyles(node, style)
applyOverflowStyles(node, style)
applyMarginStyles(node, style)
applyPaddingStyles(node, style)
applyFlexStyles(node, style)
applyDimensionStyles(node, style)
applyDisplayStyles(node, style)
applyBorderStyles(node, style, resolvedStyle)
applyGapStyles(node, style)
}
export default styles

View File

@@ -0,0 +1,57 @@
import supportsHyperlinksLib from 'supports-hyperlinks'
// 支持 OSC 8 超链接但不在 supports-hyperlinks 检测范围内的额外终端。
// 针对 TERM_PROGRAM 和 LC_TERMINAL后者在 tmux 内保留)进行检查。
export const ADDITIONAL_HYPERLINK_TERMINALS = [
'ghostty',
'Hyper',
'kitty',
'alacritty',
'iTerm.app',
'iTerm2',
]
type EnvLike = Record<string, string | undefined>
type SupportsHyperlinksOptions = {
env?: EnvLike
stdoutSupported?: boolean
}
/**
* 返回 stdout 是否支持 OSC 8 超链接。
* 使用额外的终端检测扩展 supports-hyperlinks 库。
* @param options 可选的测试覆盖env, stdoutSupported
*/
export function supportsHyperlinks(
options?: SupportsHyperlinksOptions,
): boolean {
const stdoutSupported =
options?.stdoutSupported ?? supportsHyperlinksLib.stdout
if (stdoutSupported) {
return true
}
const env = options?.env ?? process.env
// 检查 supports-hyperlinks 未检测到的额外终端
const termProgram = env['TERM_PROGRAM']
if (termProgram && ADDITIONAL_HYPERLINK_TERMINALS.includes(termProgram)) {
return true
}
// LC_TERMINAL 由某些终端设置(例如 iTerm2并在 tmux 内保留,
// TERM_PROGRAM 被覆盖为 'tmux'。
const lcTerminal = env['LC_TERMINAL']
if (lcTerminal && ADDITIONAL_HYPERLINK_TERMINALS.includes(lcTerminal)) {
return true
}
// Kitty 设置 TERM=xterm-kitty
const term = env['TERM']
if (term?.includes('kitty')) {
return true
}
return false
}

View File

@@ -0,0 +1,50 @@
// Tab 扩展,受 Ghostty 的 Tabstops.zig 启发
// 使用 8 列间隔POSIX 默认值,在 Ghostty 等终端中硬编码)
import { stringWidth } from './stringWidth.js'
import { createTokenizer } from './termio/tokenize.js'
const DEFAULT_TAB_INTERVAL = 8
/**
* 展开文本中的制表符为适当数量的空格。
* 保持 ANSI 转义序列和换行符不变。
*/
export function expandTabs(
text: string,
interval = DEFAULT_TAB_INTERVAL,
): string {
if (!text.includes('\t')) {
return text
}
const tokenizer = createTokenizer()
const tokens = tokenizer.feed(text)
tokens.push(...tokenizer.flush())
let result = ''
let column = 0
for (const token of tokens) {
if (token.type === 'sequence') {
result += token.value
} else {
const parts = token.value.split(/(\t|\n)/)
for (const part of parts) {
if (part === '\t') {
const spaces = interval - (column % interval)
result += ' '.repeat(spaces)
column += spaces
} else if (part === '\n') {
result += part
column = 0
} else {
result += part
column += stringWidth(part)
}
}
}
}
return result
}

View File

@@ -0,0 +1,47 @@
// 终端焦点状态信号 — 对 DECSET 1004 焦点事件的非 React 访问。
// 'unknown' 是不支持焦点报告的终端的默认值;
// 消费者将 'unknown' 与 'focused' 同等对待(无节流)。
// 订阅者在焦点变化时同步通知,由
// TerminalFocusProvider 使用以避免轮询。
export type TerminalFocusState = 'focused' | 'blurred' | 'unknown'
let focusState: TerminalFocusState = 'unknown'
const resolvers: Set<() => void> = new Set()
const subscribers: Set<() => void> = new Set()
export function setTerminalFocused(v: boolean): void {
focusState = v ? 'focused' : 'blurred'
// 通知 useSyncExternalStore 订阅者
for (const cb of subscribers) {
cb()
}
if (!v) {
for (const resolve of resolvers) {
resolve()
}
resolvers.clear()
}
}
export function getTerminalFocused(): boolean {
return focusState !== 'blurred'
}
export function getTerminalFocusState(): TerminalFocusState {
return focusState
}
// 用于 useSyncExternalStore
export function subscribeTerminalFocus(cb: () => void): () => void {
subscribers.add(cb)
return () => {
subscribers.delete(cb)
}
}
export function resetTerminalFocusState(): void {
focusState = 'unknown'
for (const cb of subscribers) {
cb()
}
}

View File

@@ -0,0 +1,209 @@
/**
* 查询终端并等待响应,无超时。
*
* 终端查询DECRQM、DA1、OSC 11 等)与键盘输入共享 stdin 流。
* 响应序列在语法上与按键事件可区分,因此输入解析器识别它们
* 并将它们分派到这里。
*
* 为避免超时,每个查询批次由 DA1 哨兵CSI c终止
* — 每个从 VT100 开始的终端都响应 DA1终端按顺序回答查询。
* 所以:如果你的查询响应在 DA1 之前到达,终端支持它;
* 如果 DA1 先到达,则不支持。
*
* 用法:
* const [sync, grapheme] = await Promise.all([
* querier.send(decrqm(2026)),
* querier.send(decrqm(2027)),
* querier.flush(),
* ])
* // sync 和 grapheme 是 DECRPM 响应或如果不支持则为 undefined
*/
import type { TerminalResponse } from './parse-keypress.js'
import { csi } from './termio/csi.js'
import { osc } from './termio/osc.js'
/** 终端查询:出站请求序列与匹配器配对,
* 匹配器识别预期的入站响应。由 `decrqm()`、
* `oscColor()`、`kittyKeyboard()` 等构建。 */
export type TerminalQuery<T extends TerminalResponse = TerminalResponse> = {
/** 写入 stdout 的转义序列 */
request: string
/** 在入站流中识别预期响应 */
match: (r: TerminalResponse) => r is T
}
type DecrpmResponse = Extract<TerminalResponse, { type: 'decrpm' }>
type Da1Response = Extract<TerminalResponse, { type: 'da1' }>
type Da2Response = Extract<TerminalResponse, { type: 'da2' }>
type KittyResponse = Extract<TerminalResponse, { type: 'kittyKeyboard' }>
type CursorPosResponse = Extract<TerminalResponse, { type: 'cursorPosition' }>
type OscResponse = Extract<TerminalResponse, { type: 'osc' }>
type XtversionResponse = Extract<TerminalResponse, { type: 'xtversion' }>
// -- 查询构建器 --
/** DECRQM请求 DEC 私有模式状态CSI ? mode $ p
* 终端回复 DECRPMCSI ? mode ; status $ y或忽略。 */
export function decrqm(mode: number): TerminalQuery<DecrpmResponse> {
return {
request: csi(`?${mode}$p`),
match: (r): r is DecrpmResponse => r.type === 'decrpm' && r.mode === mode,
}
}
/** 主设备属性查询CSI c。每个终端都回答此查询 —
* 在内部用作通用哨兵。如果需要 DA1 参数,可以直接调用。 */
export function da1(): TerminalQuery<Da1Response> {
return {
request: csi('c'),
match: (r): r is Da1Response => r.type === 'da1',
}
}
/** 次要设备属性查询CSI > c。返回终端版本。 */
export function da2(): TerminalQuery<Da2Response> {
return {
request: csi('>c'),
match: (r): r is Da2Response => r.type === 'da2',
}
}
/** 查询当前 Kitty 键盘协议标志CSI ? u
* 终端回复 CSI ? flags u 或忽略。 */
export function kittyKeyboard(): TerminalQuery<KittyResponse> {
return {
request: csi('?u'),
match: (r): r is KittyResponse => r.type === 'kittyKeyboard',
}
}
/** DECXCPR使用 DEC-private 标记请求光标位置CSI ? 6 n
* 终端回复 CSI ? row ; col R。`?` 标记至关重要 —
* plain DSR 形式CSI 6 n → CSI row;col R
* 修饰的 F3 键Shift+F3 = CSI 1;2 R 等)模糊。 */
export function cursorPosition(): TerminalQuery<CursorPosResponse> {
return {
request: csi('?6n'),
match: (r): r is CursorPosResponse => r.type === 'cursorPosition',
}
}
/** OSC 动态颜色查询(例如 OSC 11 用于 bg 颜色OSC 10 用于 fg
* `?` 数据槽请求终端回复当前值。 */
export function oscColor(code: number): TerminalQuery<OscResponse> {
return {
request: osc(code, '?'),
match: (r): r is OscResponse => r.type === 'osc' && r.code === code,
}
}
/** XTVERSION请求终端名称/版本CSI > 0 q
* 终端回复 DCS > | name ST例如 "xterm.js(5.5.0)")或忽略。
* 这通过 SSH 存活 — 查询通过 pty 而不是环境传递,
* 因此它识别*客户端*终端,即使 TERM_PROGRAM 没有
* 被转发。用于检测用于滚轮滚动补偿的 xterm.js。 */
export function xtversion(): TerminalQuery<XtversionResponse> {
return {
request: csi('>0q'),
match: (r): r is XtversionResponse => r.type === 'xtversion',
}
}
// -- 查询器 --
/** 哨兵请求序列DA1。保持内部flush() 写入它。 */
const SENTINEL = csi('c')
type Pending =
| {
kind: 'query'
match: (r: TerminalResponse) => boolean
resolve: (r: TerminalResponse | undefined) => void
}
| { kind: 'sentinel'; resolve: () => void }
export class TerminalQuerier {
/**
* 按发送顺序交错的查询和哨兵队列。终端
* 按顺序响应,所以每个 flush() 屏障只排空在它之前排队的查询
* — 来自独立调用者的并发批次保持隔离。
*/
private queue: Pending[] = []
constructor(private stdout: NodeJS.WriteStream) {}
/**
* 发送查询并等待其响应。
*
* 当 `query.match` 匹配传入的 TerminalResponse 时 resolve 响应,
* 或者当 flush() 哨兵在任何匹配响应之前到达时 resolve 为 `undefined`
*(意味着终端忽略了查询)。
*
* 永不拒绝;如果你从不调用 flush() 且终端不响应,
* promise 保持 pending。
*/
send<T extends TerminalResponse>(
query: TerminalQuery<T>,
): Promise<T | undefined> {
return new Promise(resolve => {
this.queue.push({
kind: 'query',
match: query.match,
resolve: r => resolve(r as T | undefined),
})
this.stdout.write(query.request)
})
}
/**
* 发送 DA1 哨兵。在 DA1 的响应到达时 resolve。
*
* 作为副作用,当 DA1 到达时所有仍在 pending 的查询都被
* resolve 为 `undefined`(终端没有响应 → 不支持该查询)。
* 这使得 send() 无超时的屏障。
*
* 在没有待处理查询时调用是安全的 — 仍然等待一轮往返。
*/
flush(): Promise<void> {
return new Promise(resolve => {
this.queue.push({ kind: 'sentinel', resolve })
this.stdout.write(SENTINEL)
})
}
/**
* 分派从 stdin 解析的响应。由 App.tsx 的
* processKeysInBatch 为每个 `kind: 'response'` 项调用。
*
* 匹配策略:
* - 首先尝试匹配待处理的查询FIFO首次匹配优先
* 这让调用者显式发送 send(da1()) 如果他们想要 DA1
* 参数 — 单独的 DA1 写入意味着终端发送两个 DA1
* 响应。第一个匹配显式查询;第二个
* (不匹配)触发哨兵。
* - 否则,如果是 DA1触发第一个待处理的哨兵
* resolve 在该哨兵之前排队的所有查询为 undefined
* (终端回答了 DA1 而没有回答它们 → 不支持)
* 并发出其 flush() 完成信号。仅排空到第一个
* 哨兵保持当多个调用者有并发查询在飞行时后面的批次完整。
* - 不请自来的响应(无匹配,无哨兵)被静默丢弃。
*/
onResponse(r: TerminalResponse): void {
const idx = this.queue.findIndex(p => p.kind === 'query' && p.match(r))
if (idx !== -1) {
const [q] = this.queue.splice(idx, 1)
if (q?.kind === 'query') q.resolve(r)
return
}
if (r.type === 'da1') {
const s = this.queue.findIndex(p => p.kind === 'sentinel')
if (s === -1) return
for (const p of this.queue.splice(0, s + 1)) {
if (p.kind === 'query') p.resolve(undefined)
else p.resolve()
}
}
}
}

View File

@@ -0,0 +1,247 @@
import { coerce } from 'semver'
import type { Writable } from 'stream'
import { env } from '../utils/env.js'
import { gte } from '../utils/semver.js'
import { getClearTerminalSequence } from './clearTerminal.js'
import type { Diff } from './frame.js'
import { cursorMove, cursorTo, eraseLines } from './termio/csi.js'
import { BSU, ESU, HIDE_CURSOR, SHOW_CURSOR } from './termio/dec.js'
import { link } from './termio/osc.js'
export type Progress = {
state: 'running' | 'completed' | 'error' | 'indeterminate'
percentage?: number
}
/**
* 检查终端是否支持 OSC 9;4 进度报告。
* 支持的终端:
* - ConEmuWindows- 所有版本
* - Ghostty 1.2.0+
* - iTerm2 3.6.6+
*
* 注意Windows Terminal 将 OSC 9;4 解释为通知,而不是进度。
*/
export function isProgressReportingAvailable(): boolean {
// 仅在有 TTY不是管道时可用
if (!process.stdout.isTTY) {
return false
}
// 明确排除 Windows Terminal它将 OSC 9;4 解释为
// 通知而不是进度指示器
if (process.env.WT_SESSION) {
return false
}
// ConEmu 支持 OSC 9;4 用于进度(所有版本)
if (
process.env.ConEmuANSI ||
process.env.ConEmuPID ||
process.env.ConEmuTask
) {
return true
}
const version = coerce(process.env.TERM_PROGRAM_VERSION)
if (!version) {
return false
}
// Ghostty 1.2.0+ 支持 OSC 9;4 用于进度
// https://ghostty.org/docs/install/release-notes/1-2-0
if (process.env.TERM_PROGRAM === 'ghostty') {
return gte(version.version, '1.2.0')
}
// iTerm2 3.6.6+ 支持 OSC 9;4 用于进度
// https://iterm2.com/downloads.html
if (process.env.TERM_PROGRAM === 'iTerm.app') {
return gte(version.version, '3.6.6')
}
return false
}
/**
* 检查终端是否支持 DEC 模式 2026同步输出
* 支持时BSU/ESU 序列可防止重绘期间出现可见闪烁。
*/
export function isSynchronizedOutputSupported(): boolean {
// tmux 解析并代理每个字节但不实现 DEC 2026。
// BSU/ESU 通过传递给外部终端,但 tmux 已经
// 通过分块破坏了原子性。跳过以节省每帧 16 字节 + 解析器工作。
if (process.env.TMUX) return false
const termProgram = process.env.TERM_PROGRAM
const term = process.env.TERM
// 具有已知 DEC 2026 支持的现代终端
if (
termProgram === 'iTerm.app' ||
termProgram === 'WezTerm' ||
termProgram === 'WarpTerminal' ||
termProgram === 'ghostty' ||
termProgram === 'contour' ||
termProgram === 'vscode' ||
termProgram === 'alacritty'
) {
return true
}
// kitty 设置 TERM=xterm-kitty 或 KITTY_WINDOW_ID
if (term?.includes('kitty') || process.env.KITTY_WINDOW_ID) return true
// Ghostty 可能设置 TERM=xterm-ghostty 而没有 TERM_PROGRAM
if (term === 'xterm-ghostty') return true
// foot 设置 TERM=foot 或 TERM=foot-extra
if (term?.startsWith('foot')) return true
// Alacritty 可能设置包含 'alacritty' 的 TERM
if (term?.includes('alacritty')) return true
// Zed 使用支持 DEC 2026 的 alacritty_terminal crate
if (process.env.ZED_TERM) return true
// Windows Terminal
if (process.env.WT_SESSION) return true
// VTE 基础终端GNOME Terminal、Tilix 等)自 VTE 0.68 起
const vteVersion = process.env.VTE_VERSION
if (vteVersion) {
const version = parseInt(vteVersion, 10)
if (version >= 6800) return true
}
return false
}
// -- XTVERSION 检测到的终端名称(在启动时异步填充)--
//
// TERM_PROGRAM 默认不通过 SSH 转发,因此基于 env 的检测
// 在 claude 在 VS Code 集成终端内远程运行时失败。
// XTVERSIONCSI > 0 q → DCS > | name ST通过 pty 传递 — 查询
// 到达*客户端*终端,回复通过 stdin 返回。
// App.tsx 在原始模式启用时触发查询setXtversionName() 从
// 响应处理程序调用。读者应将 undefined 视为"尚未知道"
// 并回退到 env-var 检测。
let xtversionName: string | undefined
/** 记录 XTVERSION 响应。从 App.tsx 当回复
* 到达 stdin 时调用一次。如果已设置则无操作(防止重新探测)。 */
export function setXtversionName(name: string): void {
if (xtversionName === undefined) xtversionName = name
}
/** 如果在基于 xterm.js 的终端中运行VS Code、Cursor、Windsurf
* 集成终端),则为 true。结合 TERM_PROGRAM env 检查(快速、同步,但
* 不通过 SSH 转发)与 XTVERSION 探测结果(异步,通过 SSH 存活
* — 查询/回复通过 pty。早期调用可能错过探测
* 回复 — 如果 SSH 检测很重要,请懒调用(例如在事件处理程序中)。 */
export function isXtermJs(): boolean {
if (process.env.TERM_PROGRAM === 'vscode') return true
return xtversionName?.startsWith('xterm.js') ?? false
}
// 已知正确实现 Kitty 键盘协议
//CSI >1u和/或 xterm modifyOtherKeysCSI >4;2m用于 ctrl+shift+<letter>
// 区分的终端。我们以前无条件启用(#23350假设
// 终端静默忽略未知 CSI — 但有些终端遵守启用
// 并发出我们的输入解析器不处理的码点(特别是在 SSH 和
// 像 VS Code 这样的基于 xterm.js 的终端上。tmux 被列入白名单,
// 因为它接受 modifyOtherKeys 且不将 kitty 序列转发给外部终端。
const EXTENDED_KEYS_TERMINALS = [
'iTerm.app',
'kitty',
'WezTerm',
'ghostty',
'tmux',
'windows-terminal',
]
/** 如果此终端正确处理扩展键报告,则为 true
* Kitty 键盘协议 + xterm modifyOtherKeys。 */
export function supportsExtendedKeys(): boolean {
return EXTENDED_KEYS_TERMINALS.includes(env.terminal ?? '')
}
/** 如果终端在收到到达可视区域上方的光标向上序列时滚动视口,则为 true。
* 在 Windows 上conhost 的 SetConsoleCursorPosition
* 跟随光标进入滚动缓冲区
* microsoft/terminal#14774在流中间将用户拖到缓冲区顶部。
* WT_SESSION 捕获 WSL-in-Windows-Terminal那里平台
* 是 linux 但输出仍然通过 conhost 路由。 */
export function hasCursorUpViewportYankBug(): boolean {
return process.platform === 'win32' || !!process.env.WT_SESSION
}
// 在模块加载时计算一次 — 终端能力在会话期间不会改变。
// 导出以便调用者可以传递同步跳过提示 gated 到特定模式。
export const SYNC_OUTPUT_SUPPORTED = isSynchronizedOutputSupported()
export type Terminal = {
stdout: Writable
stderr: Writable
}
export function writeDiffToTerminal(
terminal: Terminal,
diff: Diff,
skipSyncMarkers = false,
): void {
// 如果没有补丁则无输出
if (diff.length === 0) {
return
}
// BSU/ESU 包装是 opt-out 以保持主屏幕行为不变。
// 当终端不支持 DEC 2026例如 tmux且成本重要时
//(高频 alt-screen调用者传递 skipSyncMarkers=true。
const useSync = !skipSyncMarkers
// 将所有写入缓冲到单个字符串以避免多次写入调用
let buffer = useSync ? BSU : ''
for (const patch of diff) {
switch (patch.type) {
case 'stdout':
buffer += patch.content
break
case 'clear':
if (patch.count > 0) {
buffer += eraseLines(patch.count)
}
break
case 'clearTerminal':
buffer += getClearTerminalSequence()
break
case 'cursorHide':
buffer += HIDE_CURSOR
break
case 'cursorShow':
buffer += SHOW_CURSOR
break
case 'cursorMove':
buffer += cursorMove(patch.x, patch.y)
break
case 'cursorTo':
buffer += cursorTo(patch.col)
break
case 'carriageReturn':
buffer += '\r'
break
case 'hyperlink':
buffer += link(patch.uri)
break
case 'styleStr':
buffer += patch.str
break
}
}
// 添加同步更新结束并刷新缓冲区
if (useSync) buffer += ESU
terminal.stdout.write(buffer)
}

View File

@@ -0,0 +1,42 @@
/**
* ANSI 解析器模块
*
* 受 ghostty、tmux 和 iTerm2 启发的语义 ANSI 转义序列解析器。
*
* 关键特性:
* - 语义输出:产生结构化动作,而非字符串标记
* - 流式处理:可以通过 Parser 类增量解析输入
* - 样式跟踪:在解析调用之间维护文本样式状态
* - 全面:支持 SGR、CSI、OSC、ESC 序列
*
* 用法:
*
* ```typescript
* import { Parser } from './termio.js'
*
* const parser = new Parser()
* const actions = parser.feed('\x1b[31mred\x1b[0m')
* // => [{ type: 'text', graphemes: [...], style: { fg: { type: 'named', name: 'red' }, ... } }]
* ```
*/
// 解析器
export { Parser } from './termio/parser.js'
// 类型
export type {
Action,
Color,
CursorAction,
CursorDirection,
EraseAction,
Grapheme,
LinkAction,
ModeAction,
NamedColor,
ScrollAction,
TextSegment,
TextStyle,
TitleAction,
UnderlineStyle,
} from './termio/types.js'
export { colorsEqual, defaultStyle, stylesEqual } from './termio/types.js'

View File

@@ -0,0 +1,75 @@
/**
* ANSI 控制字符和转义序列引入符
*
* 基于 ECMA-48 / ANSI X3.64 标准。
*/
/**
* C07 位)控制字符
*/
export const C0 = {
NUL: 0x00,
SOH: 0x01,
STX: 0x02,
ETX: 0x03,
EOT: 0x04,
ENQ: 0x05,
ACK: 0x06,
BEL: 0x07,
BS: 0x08,
HT: 0x09,
LF: 0x0a,
VT: 0x0b,
FF: 0x0c,
CR: 0x0d,
SO: 0x0e,
SI: 0x0f,
DLE: 0x10,
DC1: 0x11,
DC2: 0x12,
DC3: 0x13,
DC4: 0x14,
NAK: 0x15,
SYN: 0x16,
ETB: 0x17,
CAN: 0x18,
EM: 0x19,
SUB: 0x1a,
ESC: 0x1b,
FS: 0x1c,
GS: 0x1d,
RS: 0x1e,
US: 0x1f,
DEL: 0x7f,
} as const
// 用于输出生成的字符串常量
export const ESC = '\x1b'
export const BEL = '\x07'
export const SEP = ';'
/**
* 转义序列类型引入符ESC 后的字节)
*/
export const ESC_TYPE = {
CSI: 0x5b, // [ - 控制序列引入符
OSC: 0x5d, // ] - 操作系统命令
DCS: 0x50, // P - 设备控制字符串
APC: 0x5f, // _ - 应用程序命令
PM: 0x5e, // ^ - 隐私消息
SOS: 0x58, // X - 字符串开始
ST: 0x5c, // \ - 字符串终止符
} as const
/** 检查字节是否是 C0 控制字符 */
export function isC0(byte: number): boolean {
return byte < 0x20 || byte === 0x7f
}
/**
* 检查字节是否是 ESC 序列最终字节0-9, :, ;, <, =, >, ?, @ through ~
* ESC 序列的最终字节范围比 CSI 更广
*/
export function isEscFinal(byte: number): boolean {
return byte >= 0x30 && byte <= 0x7e
}

View File

@@ -0,0 +1,317 @@
/**
* CSI控制序列引入符类型
*
* CSI 命令参数的枚举和类型。
*/
import { ESC, ESC_TYPE, SEP } from './ansi.js'
export const CSI_PREFIX = ESC + String.fromCharCode(ESC_TYPE.CSI)
/**
* CSI 参数字节范围
*/
export const CSI_RANGE = {
PARAM_START: 0x30,
PARAM_END: 0x3f,
INTERMEDIATE_START: 0x20,
INTERMEDIATE_END: 0x2f,
FINAL_START: 0x40,
FINAL_END: 0x7e,
} as const
/** 检查字节是否是 CSI 参数字节 */
export function isCSIParam(byte: number): boolean {
return byte >= CSI_RANGE.PARAM_START && byte <= CSI_RANGE.PARAM_END
}
/** 检查字节是否是 CSI 中间字节 */
export function isCSIIntermediate(byte: number): boolean {
return (
byte >= CSI_RANGE.INTERMEDIATE_START && byte <= CSI_RANGE.INTERMEDIATE_END
)
}
/** 检查字节是否是 CSI 最终字节(@ through ~ */
export function isCSIFinal(byte: number): boolean {
return byte >= CSI_RANGE.FINAL_START && byte <= CSI_RANGE.FINAL_END
}
/**
* 生成 CSI 序列ESC [ p1;p2;...;pN 最终字节
* 单个参数:作为原始正文处理
* 多个参数:最后一个是最终字节,其余是按 ; 连接的参数
*/
export function csi(...args: (string | number)[]): string {
if (args.length === 0) return CSI_PREFIX
if (args.length === 1) return `${CSI_PREFIX}${args[0]}`
const params = args.slice(0, -1)
const final = args[args.length - 1]
return `${CSI_PREFIX}${params.join(SEP)}${final}`
}
/**
* CSI 最终字节 - 命令标识符
*/
export const CSI = {
// 光标移动
CUU: 0x41, // A - 光标上移
CUD: 0x42, // B - 光标下移
CUF: 0x43, // C - 光标前移
CUB: 0x44, // D - 光标后移
CNL: 0x45, // E - 光标下一行
CPL: 0x46, // F - 光标上一行
CHA: 0x47, // G - 光标水平绝对位置
CUP: 0x48, // H - 光标位置
CHT: 0x49, // I - 光标水平制表
VPA: 0x64, // d - 垂直位置绝对
HVP: 0x66, // f - 水平垂直位置
// 擦除
ED: 0x4a, // J - 显示中擦除
EL: 0x4b, // K - 行中擦除
ECH: 0x58, // X - 擦除字符
// 插入/删除
IL: 0x4c, // L - 插入行
DL: 0x4d, // M - 删除行
ICH: 0x40, // @ - 插入字符
DCH: 0x50, // P - 删除字符
// 滚动
SU: 0x53, // S - 向上滚动
SD: 0x54, // T - 向下滚动
// 模式
SM: 0x68, // h - 设置模式
RM: 0x6c, // l - 重置模式
// SGR
SGR: 0x6d, // m - 选择图形呈现
// 其他
DSR: 0x6e, // n - 设备状态报告
DECSCUSR: 0x71, // q - 设置光标样式(带空格中间符)
DECSTBM: 0x72, // r - 设置上下边距
SCOSC: 0x73, // s - 保存光标位置
SCORC: 0x75, // u - 恢复光标位置
CBT: 0x5a, // Z - 光标向后制表
} as const
/**
* 显示中擦除区域ED 命令参数)
*/
export const ERASE_DISPLAY = ['toEnd', 'toStart', 'all', 'scrollback'] as const
/**
* 行中擦除区域EL 命令参数)
*/
export const ERASE_LINE_REGION = ['toEnd', 'toStart', 'all'] as const
/**
* 光标样式DECSCUSR
*/
export type CursorStyle = 'block' | 'underline' | 'bar'
export const CURSOR_STYLES: Array<{ style: CursorStyle; blinking: boolean }> = [
{ style: 'block', blinking: true }, // 0 - 默认
{ style: 'block', blinking: true }, // 1
{ style: 'block', blinking: false }, // 2
{ style: 'underline', blinking: true }, // 3
{ style: 'underline', blinking: false }, // 4
{ style: 'bar', blinking: true }, // 5
{ style: 'bar', blinking: false }, // 6
]
// 光标移动生成器
/** 上移 n 行CSI n A */
export function cursorUp(n = 1): string {
return n === 0 ? '' : csi(n, 'A')
}
/** 下移 n 行CSI n B */
export function cursorDown(n = 1): string {
return n === 0 ? '' : csi(n, 'B')
}
/** 前移 n 列CSI n C */
export function cursorForward(n = 1): string {
return n === 0 ? '' : csi(n, 'C')
}
/** 后移 n 列CSI n D */
export function cursorBack(n = 1): string {
return n === 0 ? '' : csi(n, 'D')
}
/** 移动到列 n1-indexedCSI n G */
export function cursorTo(col: number): string {
return csi(col, 'G')
}
/** 移动到列 1CSI G */
export const CURSOR_LEFT = csi('G')
/** 移动到行1-indexedCSI row ; col H */
export function cursorPosition(row: number, col: number): string {
return csi(row, col, 'H')
}
/** 移动到起始位置CSI H */
export const CURSOR_HOME = csi('H')
/**
* 相对于当前位置移动光标
* 正 x = 右,负 x = 左
* 正 y = 下,负 y = 上
*/
export function cursorMove(x: number, y: number): string {
let result = ''
// 先水平(与 ansi-escapes 行为匹配)
if (x < 0) {
result += cursorBack(-x)
} else if (x > 0) {
result += cursorForward(x)
}
// 然后垂直
if (y < 0) {
result += cursorUp(-y)
} else if (y > 0) {
result += cursorDown(y)
}
return result
}
// 保存/恢复光标位置
/** 保存光标位置CSI s */
export const CURSOR_SAVE = csi('s')
/** 恢复光标位置CSI u */
export const CURSOR_RESTORE = csi('u')
// 擦除生成器
/** 从光标擦除到行尾CSI K */
export function eraseToEndOfLine(): string {
return csi('K')
}
/** 从光标擦除到行首CSI 1 K */
export function eraseToStartOfLine(): string {
return csi(1, 'K')
}
/** 擦除整行CSI 2 K */
export function eraseLine(): string {
return csi(2, 'K')
}
/** 擦除整行 - 常量形式 */
export const ERASE_LINE = csi(2, 'K')
/** 从光标擦除到屏幕末尾CSI J */
export function eraseToEndOfScreen(): string {
return csi('J')
}
/** 从光标擦除到屏幕开头CSI 1 J */
export function eraseToStartOfScreen(): string {
return csi(1, 'J')
}
/** 擦除整个屏幕CSI 2 J */
export function eraseScreen(): string {
return csi(2, 'J')
}
/** 擦除整个屏幕 - 常量形式 */
export const ERASE_SCREEN = csi(2, 'J')
/** 擦除滚动缓冲区CSI 3 J */
export const ERASE_SCROLLBACK = csi(3, 'J')
/**
* 从光标行开始擦除 n 行,向上移动
* 擦除每一行并上移,最后在列 1 结束
*/
export function eraseLines(n: number): string {
if (n <= 0) return ''
let result = ''
for (let i = 0; i < n; i++) {
result += ERASE_LINE
if (i < n - 1) {
result += cursorUp(1)
}
}
result += CURSOR_LEFT
return result
}
// 滚动
/** 向上滚动 n 行CSI n S */
export function scrollUp(n = 1): string {
return n === 0 ? '' : csi(n, 'S')
}
/** 向下滚动 n 行CSI n T */
export function scrollDown(n = 1): string {
return n === 0 ? '' : csi(n, 'T')
}
/** 设置滚动区域DECSTBM, CSI top;bottom r。1-indexed包含。 */
export function setScrollRegion(top: number, bottom: number): string {
return csi(top, bottom, 'r')
}
/** 重置滚动区域到全屏DECSTBM, CSI r。光标归位。 */
export const RESET_SCROLL_REGION = csi('r')
// 括号粘贴标记(来自终端的输入,不是输出)
// 当启用括号粘贴模式时(通过 DEC 模式 2004终端发送这些来分隔粘贴内容
/** 终端在粘贴内容之前发送CSI 200 ~ */
export const PASTE_START = csi('200~')
/** 终端在粘贴内容之后发送CSI 201 ~ */
export const PASTE_END = csi('201~')
// 焦点事件标记(来自终端的输入,不是输出)
// 当焦点事件模式启用时(通过 DEC 模式 1004终端在焦点变更时发送这些
/** 终端获得焦点时发送CSI I */
export const FOCUS_IN = csi('I')
/** 终端失去焦点时发送CSI O */
export const FOCUS_OUT = csi('O')
// Kitty 键盘协议CSI u
// 启用带有修饰符信息的增强键报告
// 参考https://sw.kovidgoyal.net/kitty/keyboard-protocol/
/**
* 启用带有基本修饰符报告的 Kitty 键盘协议
* CSI > 1 u - 以 flags=1 推送模式(消除转义码歧义)
* 这使 Shift+Enter 发送 CSI 13;2 u 而不是仅仅 CR
*/
export const ENABLE_KITTY_KEYBOARD = csi('>1u')
/**
* 禁用 Kitty 键盘协议
* CSI < u - 弹出键盘模式堆栈
*/
export const DISABLE_KITTY_KEYBOARD = csi('<u')
/**
* 启用 xterm modifyOtherKeys 级别 2。
* tmux 接受这个(不是 kitty 堆栈)来启用扩展键——当
* extended-keys-format 是 csi-u 时tmux 随后以 kitty 格式发出键。
*/
export const ENABLE_MODIFY_OTHER_KEYS = csi('>4;2m')
/**
* 禁用 xterm modifyOtherKeys重置为默认
*/
export const DISABLE_MODIFY_OTHER_KEYS = csi('>4m')

View File

@@ -0,0 +1,60 @@
/**
* DEC数字设备公司私有模式序列
*
* DEC 私有模式使用 CSI ? N h设置和 CSI ? N l重置格式。
* 这些是 ANSI 标准的终端特定扩展。
*/
import { csi } from './csi.js'
/**
* DEC 私有模式编号
*/
export const DEC = {
CURSOR_VISIBLE: 25,
ALT_SCREEN: 47,
ALT_SCREEN_CLEAR: 1049,
MOUSE_NORMAL: 1000,
MOUSE_BUTTON: 1002,
MOUSE_ANY: 1003,
MOUSE_SGR: 1006,
FOCUS_EVENTS: 1004,
BRACKETED_PASTE: 2004,
SYNCHRONIZED_UPDATE: 2026,
} as const
/** 生成 CSI ? N h 序列(设置模式) */
export function decset(mode: number): string {
return csi(`?${mode}h`)
}
/** 生成 CSI ? N l 序列(重置模式) */
export function decreset(mode: number): string {
return csi(`?${mode}l`)
}
// 预生成常见模式的序列
export const BSU = decset(DEC.SYNCHRONIZED_UPDATE)
export const ESU = decreset(DEC.SYNCHRONIZED_UPDATE)
export const EBP = decset(DEC.BRACKETED_PASTE)
export const DBP = decreset(DEC.BRACKETED_PASTE)
export const EFE = decset(DEC.FOCUS_EVENTS)
export const DFE = decreset(DEC.FOCUS_EVENTS)
export const SHOW_CURSOR = decset(DEC.CURSOR_VISIBLE)
export const HIDE_CURSOR = decreset(DEC.CURSOR_VISIBLE)
export const ENTER_ALT_SCREEN = decset(DEC.ALT_SCREEN_CLEAR)
export const EXIT_ALT_SCREEN = decreset(DEC.ALT_SCREEN_CLEAR)
// 鼠标追踪1000 报告按钮按下/释放/滚轮1002 添加拖拽
// 事件button-motion1003 添加 all-motion无按钮按住 — 用于
// 悬停1006 使用 SGR 格式CSI < btn;col;row M/m而不是遗留的
// X10 字节。组合:滚轮 + 点击/拖拽选择 + 悬停。
export const ENABLE_MOUSE_TRACKING =
decset(DEC.MOUSE_NORMAL) +
decset(DEC.MOUSE_BUTTON) +
decset(DEC.MOUSE_ANY) +
decset(DEC.MOUSE_SGR)
export const DISABLE_MOUSE_TRACKING =
decreset(DEC.MOUSE_SGR) +
decreset(DEC.MOUSE_ANY) +
decreset(DEC.MOUSE_BUTTON) +
decreset(DEC.MOUSE_NORMAL)

View File

@@ -0,0 +1,67 @@
/**
* ESC 序列解析器
*
* 处理简单转义序列ESC + 一个或两个字符
*/
import type { Action } from './types.js'
/**
* 解析简单 ESC 序列
*
* @param chars - ESC 后的字符(不包括 ESC 本身)
*/
export function parseEsc(chars: string): Action | null {
if (chars.length === 0) return null
const first = chars[0]!
// 完全重置RIS
if (first === 'c') {
return { type: 'reset' }
}
// 保存光标DECSC
if (first === '7') {
return { type: 'cursor', action: { type: 'save' } }
}
// 恢复光标DECRC
if (first === '8') {
return { type: 'cursor', action: { type: 'restore' } }
}
// 索引 - 向下移动光标IND
if (first === 'D') {
return {
type: 'cursor',
action: { type: 'move', direction: 'down', count: 1 },
}
}
// 反向索引 - 向上移动光标RI
if (first === 'M') {
return {
type: 'cursor',
action: { type: 'move', direction: 'up', count: 1 },
}
}
// 下一行NEL
if (first === 'E') {
return { type: 'cursor', action: { type: 'nextLine', count: 1 } }
}
// 水平制表符设置HTS
if (first === 'H') {
return null // 制表符停止,不常用
}
// 字符集选择ESC ( X, ESC ) X 等)- 静默忽略
if ('()'.includes(first) && chars.length >= 2) {
return null
}
// 未知
return { type: 'unknown', sequence: `\x1b${chars}` }
}

View File

@@ -0,0 +1,317 @@
/**
* SGR选择图形呈现解析器
*
* 解析 SGR 参数并将其应用到 TextStyle。
* 处理分号(;)和冒号(:)分隔的参数。
*/
import type { NamedColor, TextStyle, UnderlineStyle } from './types.js'
import { defaultStyle } from './types.js'
const NAMED_COLORS: NamedColor[] = [
'black',
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'white',
'brightBlack',
'brightRed',
'brightGreen',
'brightYellow',
'brightBlue',
'brightMagenta',
'brightCyan',
'brightWhite',
]
const UNDERLINE_STYLES: UnderlineStyle[] = [
'none',
'single',
'double',
'curly',
'dotted',
'dashed',
]
type Param = { value: number | null; subparams: number[]; colon: boolean }
/**
* 解析参数字符串。
*/
function parseParams(str: string): Param[] {
if (str === '') return [{ value: 0, subparams: [], colon: false }]
const result: Param[] = []
let current: Param = { value: null, subparams: [], colon: false }
let num = ''
let inSub = false
for (let i = 0; i <= str.length; i++) {
const c = str[i]
if (c === ';' || c === undefined) {
const n = num === '' ? null : parseInt(num, 10)
if (inSub) {
if (n !== null) current.subparams.push(n)
} else {
current.value = n
}
result.push(current)
current = { value: null, subparams: [], colon: false }
num = ''
inSub = false
} else if (c === ':') {
const n = num === '' ? null : parseInt(num, 10)
if (!inSub) {
current.value = n
current.colon = true
inSub = true
} else {
if (n !== null) current.subparams.push(n)
}
num = ''
} else if (c >= '0' && c <= '9') {
num += c
}
}
return result
}
/**
* 解析扩展颜色格式。
*/
function parseExtendedColor(
params: Param[],
idx: number,
): { r: number; g: number; b: number } | { index: number } | null {
const p = params[idx]
if (!p) return null
if (p.colon && p.subparams.length >= 1) {
if (p.subparams[0] === 5 && p.subparams.length >= 2) {
return { index: p.subparams[1]! }
}
if (p.subparams[0] === 2 && p.subparams.length >= 4) {
const off = p.subparams.length >= 5 ? 1 : 0
return {
r: p.subparams[1 + off]!,
g: p.subparams[2 + off]!,
b: p.subparams[3 + off]!,
}
}
}
const next = params[idx + 1]
if (!next) return null
if (
next.value === 5 &&
params[idx + 2]?.value !== null &&
params[idx + 2]?.value !== undefined
) {
return { index: params[idx + 2]!.value! }
}
if (next.value === 2) {
const r = params[idx + 2]?.value
const g = params[idx + 3]?.value
const b = params[idx + 4]?.value
if (
r !== null &&
r !== undefined &&
g !== null &&
g !== undefined &&
b !== null &&
b !== undefined
) {
return { r, g, b }
}
}
return null
}
/**
* 应用 SGR 参数到文本样式。
*/
export function applySGR(paramStr: string, style: TextStyle): TextStyle {
const params = parseParams(paramStr)
let s = { ...style }
let i = 0
while (i < params.length) {
const p = params[i]!
const code = p.value ?? 0
if (code === 0) {
s = defaultStyle()
i++
continue
}
if (code === 1) {
s.bold = true
i++
continue
}
if (code === 2) {
s.dim = true
i++
continue
}
if (code === 3) {
s.italic = true
i++
continue
}
if (code === 4) {
s.underline = p.colon
? (UNDERLINE_STYLES[p.subparams[0]!] ?? 'single')
: 'single'
i++
continue
}
if (code === 5 || code === 6) {
s.blink = true
i++
continue
}
if (code === 7) {
s.inverse = true
i++
continue
}
if (code === 8) {
s.hidden = true
i++
continue
}
if (code === 9) {
s.strikethrough = true
i++
continue
}
if (code === 21) {
s.underline = 'double'
i++
continue
}
if (code === 22) {
s.bold = false
s.dim = false
i++
continue
}
if (code === 23) {
s.italic = false
i++
continue
}
if (code === 24) {
s.underline = 'none'
i++
continue
}
if (code === 25) {
s.blink = false
i++
continue
}
if (code === 27) {
s.inverse = false
i++
continue
}
if (code === 28) {
s.hidden = false
i++
continue
}
if (code === 29) {
s.strikethrough = false
i++
continue
}
if (code === 53) {
s.overline = true
i++
continue
}
if (code === 55) {
s.overline = false
i++
continue
}
if (code >= 30 && code <= 37) {
s.fg = { type: 'named', name: NAMED_COLORS[code - 30]! }
i++
continue
}
if (code === 39) {
s.fg = { type: 'default' }
i++
continue
}
if (code >= 40 && code <= 47) {
s.bg = { type: 'named', name: NAMED_COLORS[code - 40]! }
i++
continue
}
if (code === 49) {
s.bg = { type: 'default' }
i++
continue
}
if (code >= 90 && code <= 97) {
s.fg = { type: 'named', name: NAMED_COLORS[code - 90 + 8]! }
i++
continue
}
if (code >= 100 && code <= 107) {
s.bg = { type: 'named', name: NAMED_COLORS[code - 100 + 8]! }
i++
continue
}
if (code === 38) {
const c = parseExtendedColor(params, i)
if (c) {
s.fg =
'index' in c
? { type: 'indexed', index: c.index }
: { type: 'rgb', ...c }
i += p.colon ? 1 : 'index' in c ? 3 : 5
continue
}
}
if (code === 48) {
const c = parseExtendedColor(params, i)
if (c) {
s.bg =
'index' in c
? { type: 'indexed', index: c.index }
: { type: 'rgb', ...c }
i += p.colon ? 1 : 'index' in c ? 3 : 5
continue
}
}
if (code === 58) {
const c = parseExtendedColor(params, i)
if (c) {
s.underlineColor =
'index' in c
? { type: 'indexed', index: c.index }
: { type: 'rgb', ...c }
i += p.colon ? 1 : 'index' in c ? 3 : 5
continue
}
}
if (code === 59) {
s.underlineColor = { type: 'default' }
i++
continue
}
i++
}
return s
}

View File

@@ -0,0 +1,394 @@
/**
* ANSI 解析器 - 语义动作生成器
*
* 用于 ANSI 转义序列的流式解析器,产生语义动作。
* 使用分词器检测转义序列边界,然后解释
* 每个序列以产生结构化动作。
*
* 关键设计决策:
* - 流式:可以增量处理输入
* - 语义输出:产生结构化动作,而不是字符串标记
* - 样式跟踪:维护当前文本样式状态
*/
import { getGraphemeSegmenter } from '../../utils/intl.js'
import { C0 } from './ansi.js'
import { CSI, CURSOR_STYLES, ERASE_DISPLAY, ERASE_LINE_REGION } from './csi.js'
import { DEC } from './dec.js'
import { parseEsc } from './esc.js'
import { parseOSC } from './osc.js'
import { applySGR } from './sgr.js'
import { createTokenizer, type Token, type Tokenizer } from './tokenize.js'
import type { Action, Grapheme, TextStyle } from './types.js'
import { defaultStyle } from './types.js'
// =============================================================================
// 字素工具
// =============================================================================
function isEmoji(codePoint: number): boolean {
return (
(codePoint >= 0x2600 && codePoint <= 0x26ff) ||
(codePoint >= 0x2700 && codePoint <= 0x27bf) ||
(codePoint >= 0x1f300 && codePoint <= 0x1f9ff) ||
(codePoint >= 0x1fa00 && codePoint <= 0x1faff) ||
(codePoint >= 0x1f1e0 && codePoint <= 0x1f1ff)
)
}
function isEastAsianWide(codePoint: number): boolean {
return (
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
(codePoint >= 0x2e80 && codePoint <= 0x9fff) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe1f) ||
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
(codePoint >= 0x20000 && codePoint <= 0x2fffd) ||
(codePoint >= 0x30000 && codePoint <= 0x3fffd)
)
}
function hasMultipleCodepoints(str: string): boolean {
let count = 0
for (const _ of str) {
count++
if (count > 1) return true
}
return false
}
function graphemeWidth(grapheme: string): 1 | 2 {
if (hasMultipleCodepoints(grapheme)) return 2
const codePoint = grapheme.codePointAt(0)
if (codePoint === undefined) return 1
if (isEmoji(codePoint) || isEastAsianWide(codePoint)) return 2
return 1
}
function* segmentGraphemes(str: string): Generator<Grapheme> {
for (const { segment } of getGraphemeSegmenter().segment(str)) {
yield { value: segment, width: graphemeWidth(segment) }
}
}
// =============================================================================
// 序列解析
// =============================================================================
function parseCSIParams(paramStr: string): number[] {
if (paramStr === '') return []
return paramStr.split(/[;:]/).map(s => (s === '' ? 0 : parseInt(s, 10)))
}
/** 解析原始 CSI 序列(例如 "\x1b[31m")为动作 */
function parseCSI(rawSequence: string): Action | null {
const inner = rawSequence.slice(2)
if (inner.length === 0) return null
const finalByte = inner.charCodeAt(inner.length - 1)
const beforeFinal = inner.slice(0, -1)
let privateMode = ''
let paramStr = beforeFinal
let intermediate = ''
if (beforeFinal.length > 0 && '?>='.includes(beforeFinal[0]!)) {
privateMode = beforeFinal[0]!
paramStr = beforeFinal.slice(1)
}
const intermediateMatch = paramStr.match(/([^0-9;:]+)$/)
if (intermediateMatch) {
intermediate = intermediateMatch[1]!
paramStr = paramStr.slice(0, -intermediate.length)
}
const params = parseCSIParams(paramStr)
const p0 = params[0] ?? 1
const p1 = params[1] ?? 1
// SGR选择图形呈现
if (finalByte === CSI.SGR && privateMode === '') {
return { type: 'sgr', params: paramStr }
}
// 光标移动
if (finalByte === CSI.CUU) {
return {
type: 'cursor',
action: { type: 'move', direction: 'up', count: p0 },
}
}
if (finalByte === CSI.CUD) {
return {
type: 'cursor',
action: { type: 'move', direction: 'down', count: p0 },
}
}
if (finalByte === CSI.CUF) {
return {
type: 'cursor',
action: { type: 'move', direction: 'forward', count: p0 },
}
}
if (finalByte === CSI.CUB) {
return {
type: 'cursor',
action: { type: 'move', direction: 'back', count: p0 },
}
}
if (finalByte === CSI.CNL) {
return { type: 'cursor', action: { type: 'nextLine', count: p0 } }
}
if (finalByte === CSI.CPL) {
return { type: 'cursor', action: { type: 'prevLine', count: p0 } }
}
if (finalByte === CSI.CHA) {
return { type: 'cursor', action: { type: 'column', col: p0 } }
}
if (finalByte === CSI.CUP || finalByte === CSI.HVP) {
return { type: 'cursor', action: { type: 'position', row: p0, col: p1 } }
}
if (finalByte === CSI.VPA) {
return { type: 'cursor', action: { type: 'row', row: p0 } }
}
// 擦除
if (finalByte === CSI.ED) {
const region = ERASE_DISPLAY[params[0] ?? 0] ?? 'toEnd'
return { type: 'erase', action: { type: 'display', region } }
}
if (finalByte === CSI.EL) {
const region = ERASE_LINE_REGION[params[0] ?? 0] ?? 'toEnd'
return { type: 'erase', action: { type: 'line', region } }
}
if (finalByte === CSI.ECH) {
return { type: 'erase', action: { type: 'chars', count: p0 } }
}
// 滚动
if (finalByte === CSI.SU) {
return { type: 'scroll', action: { type: 'up', count: p0 } }
}
if (finalByte === CSI.SD) {
return { type: 'scroll', action: { type: 'down', count: p0 } }
}
if (finalByte === CSI.DECSTBM) {
return {
type: 'scroll',
action: { type: 'setRegion', top: p0, bottom: p1 },
}
}
// 光标保存/恢复
if (finalByte === CSI.SCOSC) {
return { type: 'cursor', action: { type: 'save' } }
}
if (finalByte === CSI.SCORC) {
return { type: 'cursor', action: { type: 'restore' } }
}
// 光标样式
if (finalByte === CSI.DECSCUSR && intermediate === ' ') {
const styleInfo = CURSOR_STYLES[p0] ?? CURSOR_STYLES[0]!
return { type: 'cursor', action: { type: 'style', ...styleInfo } }
}
// 私有模式
if (privateMode === '?' && (finalByte === CSI.SM || finalByte === CSI.RM)) {
const enabled = finalByte === CSI.SM
if (p0 === DEC.CURSOR_VISIBLE) {
return {
type: 'cursor',
action: enabled ? { type: 'show' } : { type: 'hide' },
}
}
if (p0 === DEC.ALT_SCREEN_CLEAR || p0 === DEC.ALT_SCREEN) {
return { type: 'mode', action: { type: 'alternateScreen', enabled } }
}
if (p0 === DEC.BRACKETED_PASTE) {
return { type: 'mode', action: { type: 'bracketedPaste', enabled } }
}
if (p0 === DEC.MOUSE_NORMAL) {
return {
type: 'mode',
action: { type: 'mouseTracking', mode: enabled ? 'normal' : 'off' },
}
}
if (p0 === DEC.MOUSE_BUTTON) {
return {
type: 'mode',
action: { type: 'mouseTracking', mode: enabled ? 'button' : 'off' },
}
}
if (p0 === DEC.MOUSE_ANY) {
return {
type: 'mode',
action: { type: 'mouseTracking', mode: enabled ? 'any' : 'off' },
}
}
if (p0 === DEC.FOCUS_EVENTS) {
return { type: 'mode', action: { type: 'focusEvents', enabled } }
}
}
return { type: 'unknown', sequence: rawSequence }
}
/**
* 从原始形式识别转义序列的类型。
*/
function identifySequence(
seq: string,
): 'csi' | 'osc' | 'esc' | 'ss3' | 'unknown' {
if (seq.length < 2) return 'unknown'
if (seq.charCodeAt(0) !== C0.ESC) return 'unknown'
const second = seq.charCodeAt(1)
if (second === 0x5b) return 'csi' // [
if (second === 0x5d) return 'osc' // ]
if (second === 0x4f) return 'ss3' // O
return 'esc'
}
// =============================================================================
// 主解析器
// =============================================================================
/**
* Parser 类 - 维护流式/增量解析的状态
*
* 用法:
* ```typescript
* const parser = new Parser()
* const actions1 = parser.feed('partial\x1b[')
* const actions2 = parser.feed('31mred') // 状态在内部维护
* ```
*/
export class Parser {
private tokenizer: Tokenizer = createTokenizer()
style: TextStyle = defaultStyle()
inLink = false
linkUrl: string | undefined
reset(): void {
this.tokenizer.reset()
this.style = defaultStyle()
this.inLink = false
this.linkUrl = undefined
}
/** 喂入输入并获取结果动作 */
feed(input: string): Action[] {
const tokens = this.tokenizer.feed(input)
const actions: Action[] = []
for (const token of tokens) {
const tokenActions = this.processToken(token)
actions.push(...tokenActions)
}
return actions
}
private processToken(token: Token): Action[] {
switch (token.type) {
case 'text':
return this.processText(token.value)
case 'sequence':
return this.processSequence(token.value)
}
}
private processText(text: string): Action[] {
// 处理嵌入文本中的 BEL 字符
const actions: Action[] = []
let current = ''
for (const char of text) {
if (char.charCodeAt(0) === C0.BEL) {
if (current) {
const graphemes = [...segmentGraphemes(current)]
if (graphemes.length > 0) {
actions.push({ type: 'text', graphemes, style: { ...this.style } })
}
current = ''
}
actions.push({ type: 'bell' })
} else {
current += char
}
}
if (current) {
const graphemes = [...segmentGraphemes(current)]
if (graphemes.length > 0) {
actions.push({ type: 'text', graphemes, style: { ...this.style } })
}
}
return actions
}
private processSequence(seq: string): Action[] {
const seqType = identifySequence(seq)
switch (seqType) {
case 'csi': {
const action = parseCSI(seq)
if (!action) return []
if (action.type === 'sgr') {
this.style = applySGR(action.params, this.style)
return []
}
return [action]
}
case 'osc': {
// 提取 OSC 内容ESC ] 和终止符之间)
let content = seq.slice(2)
// 移除终止符BEL 或 ESC \
if (content.endsWith('\x07')) {
content = content.slice(0, -1)
} else if (content.endsWith('\x1b\\')) {
content = content.slice(0, -2)
}
const action = parseOSC(content)
if (action) {
if (action.type === 'link') {
if (action.action.type === 'start') {
this.inLink = true
this.linkUrl = action.action.url
} else {
this.inLink = false
this.linkUrl = undefined
}
}
return [action]
}
return []
}
case 'esc': {
const escContent = seq.slice(1)
const action = parseEsc(escContent)
return action ? [action] : []
}
case 'ss3':
// SS3 序列通常是应用程序模式下的光标键
// 对于输出解析,视为未知
return [{ type: 'unknown', sequence: seq }]
default:
return [{ type: 'unknown', sequence: seq }]
}
}
}

View File

@@ -0,0 +1,489 @@
/**
* OSC操作系统命令类型和解析器
*/
import { Buffer } from 'buffer'
import { env } from '../../utils/env.js'
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js'
import type { Action, Color, TabStatusAction } from './types.js'
export const OSC_PREFIX = ESC + String.fromCharCode(ESC_TYPE.OSC)
/** 字符串终止符ESC \- 替代 BEL 用于终止 OSC */
export const ST = ESC + '\\'
/**
* 生成 OSC 序列ESC ] p1;p2;...;pN <terminator>
* 对于 Kitty 使用 ST 终止符(避免蜂鸣),其他使用 BEL
*/
export function osc(...parts: (string | number)[]): string {
const terminator = env.terminal === 'kitty' ? ST : BEL
return `${OSC_PREFIX}${parts.join(SEP)}${terminator}`
}
/**
* 为终端多路复用器穿透包装转义序列。
* tmux 和 GNU screen 拦截转义序列DCS 穿透
* 将它们不加修改地隧道传输到外部终端。
*
* tmux 3.3+ 通过 `allow-passthrough`(默认关闭)控制此功能。
* 关闭时tmux 静默丢弃整个 DCS——没有垃圾比未包装的 OSC 差不了多少。
* 想要穿透的用户在他们的 .tmux.conf 中设置;我们不改变它。
*
* 不要包装 BEL原始 \x07 触发 tmux 的 bell-action窗口标志
* 包装的 \x07 是不透明的 DCS 负载tmux 看不到铃声。
*/
export function wrapForMultiplexer(sequence: string): string {
if (process.env['TMUX']) {
const escaped = sequence.replaceAll('\x1b', '\x1b\x1b')
return `\x1bPtmux;${escaped}\x1b\\`
}
if (process.env['STY']) {
return `\x1bP${sequence}\x1b\\`
}
return sequence
}
/**
* 根据环境状态决定 setClipboard() 将采用哪种路径。同步的,因此
* 调用者可以在不等待复制本身的情况下显示可靠的 toast。
*
* - 'native': pbcopy或等效工具将运行——高可信度的系统
* 剪贴板写入。tmux 缓冲区也可以作为奖励加载。
* - 'tmux-buffer': tmux load-buffer 将运行,但没有本机工具——粘贴
* 使用 prefix+] 有效。系统剪贴板取决于 tmux 的 set-clipboard
* 选项 + 外部终端 OSC 52 支持;无法从这里知道。
* - 'osc52': 只有原始 OSC 52 序列将被写入 stdout。
* 尽力而为iTerm2 默认禁用 OSC 52。
*
* pbcopy 门控特别使用 SSH_CONNECTION而不是 SSH_TTY——tmux 窗格
* 继承 SSH_TTY 即使在本地重新附加后也永远存在,但 SSH_CONNECTION 在
* tmux 的默认 update-environment 集中,并在本地附加时清除。
*/
export type ClipboardPath = 'native' | 'tmux-buffer' | 'osc52'
export function getClipboardPath(): ClipboardPath {
const nativeAvailable =
process.platform === 'darwin' && !process.env['SSH_CONNECTION']
if (nativeAvailable) return 'native'
if (process.env['TMUX']) return 'tmux-buffer'
return 'osc52'
}
/**
* 在 tmux 的 DCS 穿透中包装有效载荷ESC P tmux ; <payload> ESC \
* tmux 将有效载荷转发到外部终端,绕过其自己的解析器。
* 内部 ESC 必须加倍。需要在 ~/.tmux.conf 中设置 `set -g allow-passthrough on`
* 没有它tmux 静默丢弃整个 DCS无回归
*/
function tmuxPassthrough(payload: string): string {
return `${ESC}Ptmux;${payload.replaceAll(ESC, ESC + ESC)}${ST}`
}
/**
* 通过 `tmux load-buffer` 将文本加载到 tmux 的粘贴缓冲区。
* -wtmux 3.2+)通过 tmux 自己的 OSC 52 发射传播到
* 外部终端的剪贴板。-w 对于 iTerm2 被丢弃tmux 的 OSC 52 发射
* 在 SSH 上崩溃 iTerm2 会话。
*
* 如果缓冲区加载成功则返回 true。
*/
export async function tmuxLoadBuffer(text: string): Promise<boolean> {
if (!process.env['TMUX']) return false
const args =
process.env['LC_TERMINAL'] === 'iTerm2'
? ['load-buffer', '-']
: ['load-buffer', '-w', '-']
const { code } = await execFileNoThrow('tmux', args, {
input: text,
useCwd: false,
timeout: 2000,
})
return code === 0
}
/**
* OSC 52 剪贴板写入ESC ] 52 ; c ; <base64> BEL/ST
* 'c' 选择剪贴板(与 X11 上的 'p' 主选择相对)。
*
* 在 tmux 内($TMUX 设置),`tmux load-buffer -w -` 是主要路径。
* tmux 的缓冲区始终可达——在 SSH 上工作,存活于 detach/reattach
* 对过时环境变量免疫。-w 标志tmux 3.2+)告诉 tmux 也通过其
* 自己的 OSC 52 路径传播到外部终端tmux 正确地为附加客户端包装。
* 在较旧的 tmux 上,-w 被忽略,缓冲区仍被加载。-w 对于 iTerm2 被丢弃(#22432
* 因为 tmux 自己的 OSC 52 发射空选择参数ESC]52;;b64
* 在 SSH 上崩溃 iTerm2。
*
* load-buffer 成功后,我们还返回 DCS 穿透包装的 OSC 52 给调用者写入 stdout。
* 我们的序列使用显式 `c`(不是 tmux 的崩溃空参数变体),所以它绕过 #22432 路径。
* 有了 `allow-passthrough on` + 支持 OSC-52 的外部终端,选择
* 到达系统剪贴板任一关闭tmux 静默丢弃 DCSprefix+] 仍然有效。
*
* 如果 load-buffer 完全失败,回退到原始 OSC 52。
*
* 在 tmux 外,将原始 OSC 52 写入 stdout调用者处理写入
*
* 本地(无 SSH_CONNECTION也调用本机剪贴板工具。
* OSC 52 和 tmux -w 都取决于终端设置——iTerm2 默认禁用
* OSC 52VS Code 在首次使用时显示权限提示。本机
* 工具pbcopy/wl-copy/xclip/xsel/clip.exe始终在本地工作。在
* SSH 上这些会写入远程剪贴板——OSC 52 是正确的路径。
*
* 返回调用者写入 stdout 的序列tmux 内为原始 OSC 52外部为 DCS 包装)。
*/
export async function setClipboard(text: string): Promise<string> {
const b64 = Buffer.from(text, 'utf8').toString('base64')
const raw = osc(OSC.CLIPBOARD, 'c', b64)
// 本机安全网——首先触发,在 tmux await 之前,这样快速
// 焦点切换选择不会与 pbcopy 竞争。以前这在 await tmux load-buffer 之后运行,
// 在 pbcopy 开始之前增加了约 50-100ms 的子进程延迟——
// 快速 cmd+tab → 粘贴会击败它。
// 仅在 SSH_CONNECTION不是 SSH_TTY时触发因为 tmux 窗格
// 永远继承 SSH_TTY 但 SSH_CONNECTION 在 tmux 的默认
// update-environment 中,并在本地附加时清除。触发后不管。
if (!process.env['SSH_CONNECTION']) copyNative(text)
const tmuxBufferLoaded = await tmuxLoadBuffer(text)
// 内部 OSC 直接使用 BEL不是 osc()——ST 的 ESC 也需要加倍,
// BEL 在各处都适用于 OSC 52。
if (tmuxBufferLoaded) return tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`)
return raw
}
// Linux 剪贴板工具undefined = 尚未探测null = 不可用。
// 探测顺序wl-copy (Wayland) → xclip (X11) → xsel (X11 回退)。
// 首次尝试后缓存,以便重复的 mouse-up 跳过探测链。
let linuxCopy: 'wl-copy' | 'xclip' | 'xsel' | null | undefined
/**
* 调用本机剪贴板工具作为 OSC 52 的安全网。
* 仅在非 SSH 会话时调用over SSH这些会写入
* 远程机器的剪贴板——OSC 52 是正确的路径)。
* 触发后不管:失败是静默的,因为 OSC 52 可能已成功。
*/
function copyNative(text: string): void {
const opts = { input: text, useCwd: false, timeout: 2000 }
switch (process.platform) {
case 'darwin':
void execFileNoThrow('pbcopy', [], opts)
return
case 'linux': {
if (linuxCopy === null) return
if (linuxCopy === 'wl-copy') {
void execFileNoThrow('wl-copy', [], opts)
return
}
if (linuxCopy === 'xclip') {
void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts)
return
}
if (linuxCopy === 'xsel') {
void execFileNoThrow('xsel', ['--clipboard', '--input'], opts)
return
}
// 首次调用:探测 wl-copy (Wayland) 然后 xclip/xsel (X11),缓存获胜者。
void execFileNoThrow('wl-copy', [], opts).then(r => {
if (r.code === 0) {
linuxCopy = 'wl-copy'
return
}
void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts).then(
r2 => {
if (r2.code === 0) {
linuxCopy = 'xclip'
return
}
void execFileNoThrow('xsel', ['--clipboard', '--input'], opts).then(
r3 => {
linuxCopy = r3.code === 0 ? 'xsel' : null
},
)
},
)
})
return
}
case 'win32':
// clip.exe 在 Windows 上始终可用。Unicode 处理
// 不完美(系统区域设置编码)但作为回退足够好。
void execFileNoThrow('clip', [], opts)
return
}
}
/** @internal 仅供测试使用 */
export function _resetLinuxCopyCache(): void {
linuxCopy = undefined
}
/**
* OSC 命令编号
*/
export const OSC = {
SET_TITLE_AND_ICON: 0,
SET_ICON: 1,
SET_TITLE: 2,
SET_COLOR: 4,
SET_CWD: 7,
HYPERLINK: 8,
ITERM2: 9, // iTerm2 专有序列
SET_FG_COLOR: 10,
SET_BG_COLOR: 11,
SET_CURSOR_COLOR: 12,
CLIPBOARD: 52,
KITTY: 99, // Kitty 通知协议
RESET_COLOR: 104,
RESET_FG_COLOR: 110,
RESET_BG_COLOR: 111,
RESET_CURSOR_COLOR: 112,
SEMANTIC_PROMPT: 133,
GHOSTTY: 777, // Ghostty 通知协议
TAB_STATUS: 21337, // 标签页状态扩展
} as const
/**
* 将 OSC 序列解析为动作
*
* @param content - 序列内容(不带 ESC ] 和终止符)
*/
export function parseOSC(content: string): Action | null {
const semicolonIdx = content.indexOf(';')
const command = semicolonIdx >= 0 ? content.slice(0, semicolonIdx) : content
const data = semicolonIdx >= 0 ? content.slice(semicolonIdx + 1) : ''
const commandNum = parseInt(command, 10)
// 窗口/图标标题
if (commandNum === OSC.SET_TITLE_AND_ICON) {
return { type: 'title', action: { type: 'both', title: data } }
}
if (commandNum === OSC.SET_ICON) {
return { type: 'title', action: { type: 'iconName', name: data } }
}
if (commandNum === OSC.SET_TITLE) {
return { type: 'title', action: { type: 'windowTitle', title: data } }
}
// 超链接OSC 8
if (commandNum === OSC.HYPERLINK) {
const parts = data.split(';')
const paramsStr = parts[0] ?? ''
const url = parts.slice(1).join(';')
if (url === '') {
return { type: 'link', action: { type: 'end' } }
}
const params: Record<string, string> = {}
if (paramsStr) {
for (const pair of paramsStr.split(':')) {
const eqIdx = pair.indexOf('=')
if (eqIdx >= 0) {
params[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1)
}
}
}
return {
type: 'link',
action: {
type: 'start',
url,
params: Object.keys(params).length > 0 ? params : undefined,
},
}
}
// 标签页状态OSC 21337
if (commandNum === OSC.TAB_STATUS) {
return { type: 'tabStatus', action: parseTabStatus(data) }
}
return { type: 'unknown', sequence: `\x1b]${content}` }
}
/**
* 解析 XParseColor 风格的颜色规范为 RGB Color。
* 接受 `#RRGGBB` 和 `rgb:R/G/B`(每个分量 1-4 个十六进制数字,
* 缩放到 8 位)。解析失败时返回 null。
*/
export function parseOscColor(spec: string): Color | null {
const hex = spec.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
if (hex) {
return {
type: 'rgb',
r: parseInt(hex[1]!, 16),
g: parseInt(hex[2]!, 16),
b: parseInt(hex[3]!, 16),
}
}
const rgb = spec.match(
/^rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})$/i,
)
if (rgb) {
// XParseColor: N 个十六进制数字 → 值 / (16^N - 1),缩放到 0-255
const scale = (s: string) =>
Math.round((parseInt(s, 16) / (16 ** s.length - 1)) * 255)
return {
type: 'rgb',
r: scale(rgb[1]!),
g: scale(rgb[2]!),
b: scale(rgb[3]!),
}
}
return null
}
/**
* 解析 OSC 21337 有效载荷:`key=value;key=value;...`
* 值内部有 `\;` 和 `\\` 转义。裸 key 或 `key=` 清除该字段;
* 未知 key 被忽略。
*/
function parseTabStatus(data: string): TabStatusAction {
const action: TabStatusAction = {}
for (const [key, value] of splitTabStatusPairs(data)) {
switch (key) {
case 'indicator':
action.indicator = value === '' ? null : parseOscColor(value)
break
case 'status':
action.status = value === '' ? null : value
break
case 'status-color':
action.statusColor = value === '' ? null : parseOscColor(value)
break
}
}
return action
}
/** 分割 `k=v;k=v` 并遵守 `\;` 和 `\\` 转义。生成 [key, 未转义值]。 */
function* splitTabStatusPairs(data: string): Generator<[string, string]> {
let key = ''
let val = ''
let inVal = false
let esc = false
for (const c of data) {
if (esc) {
if (inVal) val += c
else key += c
esc = false
} else if (c === '\\') {
esc = true
} else if (c === ';') {
yield [key, val]
key = ''
val = ''
inVal = false
} else if (c === '=' && !inVal) {
inVal = true
} else if (inVal) {
val += c
} else {
key += c
}
}
if (key || inVal) yield [key, val]
}
// 输出生成器
/** 开始超链接OSC 8。自动分配从 URL 派生的 id= 参数,
* 以便终端将同一链接的换行组合在一起(规范说具有匹配 URI *和*
* 非空 id 的单元格被连接;没有 id每个
* 换行是单独的链接——悬停不一致,工具提示部分)。
* 空 url = 关闭序列(根据规范的空参数)。*/
export function link(url: string, params?: Record<string, string>): string {
if (!url) return LINK_END
const p = { id: osc8Id(url), ...params }
const paramStr = Object.entries(p)
.map(([k, v]) => `${k}=${v}`)
.join(':')
return osc(OSC.HYPERLINK, paramStr, url)
}
function osc8Id(url: string): string {
let h = 0
for (let i = 0; i < url.length; i++)
h = ((h << 5) - h + url.charCodeAt(i)) | 0
return (h >>> 0).toString(36)
}
/** 结束超链接OSC 8 */
export const LINK_END = osc(OSC.HYPERLINK, '', '')
// iTerm2 OSC 9 子命令
/** iTerm2 OSC 9 子命令编号 */
export const ITERM2 = {
NOTIFY: 0,
BADGE: 2,
PROGRESS: 4,
} as const
/** 进度操作码(与 ITERM2.PROGRESS 一起使用) */
export const PROGRESS = {
CLEAR: 0,
SET: 1,
ERROR: 2,
INDETERMINATE: 3,
} as const
/**
* 清除 iTerm2 进度条序列OSC 9;4;0;BEL
* 使用 BEL 终止符,因为这是清理(不是运行时通知)
* 我们希望确保无论终端类型如何都始终发送。
*/
export const CLEAR_ITERM2_PROGRESS = `${OSC_PREFIX}${OSC.ITERM2};${ITERM2.PROGRESS};${PROGRESS.CLEAR};${BEL}`
/**
* 清除终端标题序列OSC 0 和空字符串 + BEL
* 为清理使用 BEL 终止符——在所有终端上都安全。
*/
export const CLEAR_TERMINAL_TITLE = `${OSC_PREFIX}${OSC.SET_TITLE_AND_ICON};${BEL}`
/** 清除所有三个 OSC 21337 标签页状态字段。退出时使用。 */
export const CLEAR_TAB_STATUS = osc(
OSC.TAB_STATUS,
'indicator=;status=;status-color=',
)
/**
* 发出 OSC 21337标签页状态指示器的门控。仅限 Ant
* 规范不稳定时。识别它的终端静默丢弃,不支持的终端也是如此,
* 所以发出是安全的——我们不根据终端检测进行门控,
* 因为预计多个终端都会支持。
*
* 调用者必须用 wrapForMultiplexer() 包装输出,以便 tmux/screen
* DCS 穿透将序列传送到外部终端。
*/
export function supportsTabStatus(): boolean {
return process.env.USER_TYPE === 'ant'
}
/**
* 发出 OSC 21337 标签页状态序列。省略的字段保持不变;
* `null` 发送空值以清除。
* 状态文本中的 `;` 和 `\` 根据规范转义。
*/
export function tabStatus(fields: TabStatusAction): string {
const parts: string[] = []
const rgb = (c: Color) =>
c.type === 'rgb'
? `#${[c.r, c.g, c.b].map(n => n.toString(16).padStart(2, '0')).join('')}`
: ''
if ('indicator' in fields)
parts.push(`indicator=${fields.indicator ? rgb(fields.indicator) : ''}`)
if ('status' in fields)
parts.push(
`status=${fields.status?.replaceAll('\\', '\\\\').replaceAll(';', '\\;') ?? ''}`,
)
if ('statusColor' in fields)
parts.push(
`status-color=${fields.statusColor ? rgb(fields.statusColor) : ''}`,
)
return osc(OSC.TAB_STATUS, parts.join(';'))
}

View File

@@ -0,0 +1,317 @@
/**
* 输入分词器 - 转义序列边界检测
*
* 将终端输入分割成标记:文本块和原始转义序列。
* 与语义解释序列的 Parser 不同,这只是
* 为键盘输入解析识别边界。
*/
import { C0, ESC_TYPE, isEscFinal } from './ansi.js'
import { isCSIFinal, isCSIIntermediate, isCSIParam } from './csi.js'
export type Token =
| { type: 'text'; value: string }
| { type: 'sequence'; value: string }
type State =
| 'ground'
| 'escape'
| 'escapeIntermediate'
| 'csi'
| 'ss3'
| 'osc'
| 'dcs'
| 'apc'
export type Tokenizer = {
/** 喂入输入并获取结果标记 */
feed(input: string): Token[]
/** 刷新任何缓冲的不完整序列 */
flush(): Token[]
/** 重置分词器状态 */
reset(): void
/** 获取任何缓冲的不完整序列 */
buffer(): string
}
type TokenizerOptions = {
/**
* 将 `CSI M` 视为 X10 鼠标事件前缀并消耗 3 个有效载荷字节。
* 仅对 stdin 输入启用 - `\x1b[M` 在输出流中也是 CSI DL删除行
* 在那里启用会吞掉显示文本。默认为 false。
*/
x10Mouse?: boolean
}
/**
* 创建终端输入的流式分词器。
*
* 用法:
* ```typescript
* const tokenizer = createTokenizer()
* const tokens1 = tokenizer.feed('hello\x1b[')
* const tokens2 = tokenizer.feed('A') // 完成转义序列
* const remaining = tokenizer.flush() // 强制输出不完整序列
* ```
*/
export function createTokenizer(options?: TokenizerOptions): Tokenizer {
let currentState: State = 'ground'
let currentBuffer = ''
const x10Mouse = options?.x10Mouse ?? false
return {
feed(input: string): Token[] {
const result = tokenize(
input,
currentState,
currentBuffer,
false,
x10Mouse,
)
currentState = result.state.state
currentBuffer = result.state.buffer
return result.tokens
},
flush(): Token[] {
const result = tokenize('', currentState, currentBuffer, true, x10Mouse)
currentState = result.state.state
currentBuffer = result.state.buffer
return result.tokens
},
reset(): void {
currentState = 'ground'
currentBuffer = ''
},
buffer(): string {
return currentBuffer
},
}
}
type InternalState = {
state: State
buffer: string
}
function tokenize(
input: string,
initialState: State,
initialBuffer: string,
flush: boolean,
x10Mouse: boolean,
): { tokens: Token[]; state: InternalState } {
const tokens: Token[] = []
const result: InternalState = {
state: initialState,
buffer: '',
}
const data = initialBuffer + input
let i = 0
let textStart = 0
let seqStart = 0
const flushText = (): void => {
if (i > textStart) {
const text = data.slice(textStart, i)
if (text) {
tokens.push({ type: 'text', value: text })
}
}
textStart = i
}
const emitSequence = (seq: string): void => {
if (seq) {
tokens.push({ type: 'sequence', value: seq })
}
result.state = 'ground'
textStart = i
}
while (i < data.length) {
const code = data.charCodeAt(i)
switch (result.state) {
case 'ground':
if (code === C0.ESC) {
flushText()
seqStart = i
result.state = 'escape'
i++
} else {
i++
}
break
case 'escape':
if (code === ESC_TYPE.CSI) {
result.state = 'csi'
i++
} else if (code === ESC_TYPE.OSC) {
result.state = 'osc'
i++
} else if (code === ESC_TYPE.DCS) {
result.state = 'dcs'
i++
} else if (code === ESC_TYPE.APC) {
result.state = 'apc'
i++
} else if (code === 0x4f) {
// 'O' - SS3
result.state = 'ss3'
i++
} else if (isCSIIntermediate(code)) {
// 中间字节(例如 ESC ( 用于字符集) - 继续缓冲
result.state = 'escapeIntermediate'
i++
} else if (isEscFinal(code)) {
// 两字符转义序列
i++
emitSequence(data.slice(seqStart, i))
} else if (code === C0.ESC) {
// 双转义 - 发出第一个,开始新的
emitSequence(data.slice(seqStart, i))
seqStart = i
result.state = 'escape'
i++
} else {
// 无效 - 将 ESC 视为文本
result.state = 'ground'
textStart = seqStart
}
break
case 'escapeIntermediate':
// 在中间字节之后,等待最终字节
if (isCSIIntermediate(code)) {
// 更多中间字节
i++
} else if (isEscFinal(code)) {
// 最终字节 - 完成序列
i++
emitSequence(data.slice(seqStart, i))
} else {
// 无效 - 视为文本
result.state = 'ground'
textStart = seqStart
}
break
case 'csi':
// X10 鼠标CSI M + 3 个原始有效载荷字节Cb+32, Cx+32, Cy+32
// M 紧跟在 [ 之后(偏移 2意味着没有参数 — SGR 鼠标
// CSI < … M有 `<` 参数字节作为第一个,到达 M 时偏移 > 2。
// 忽略 DECSET 1006 但遵从 1000/1002 的终端发出这种
// 遗留编码没有这个分支3 个有效载荷字节会泄露
// 为文本(`` `rK `` / `arK` 乱码在提示符中)。
//
// 在 x10Mouse 上门控 - `\x1b[M` 也是 CSI DL删除行盲目
// 消耗 3 个字符会破坏输出渲染Parser/Ansi
// 并碎片化 bracketed-paste PASTE_END。只有 stdin 启用这个。
// 每个有效载荷槽上的 ≥0x20 检查是万无一失的X10
// 保证 Cb≥32, Cx≥33, Cy≥33所以任何槽中的控制字节ESC=0x1B意味着
// 这是相邻另一个序列的 CSI DL不是鼠标事件。检查所有三个槽
// 防止 PASTE_END 的 ESC 在粘贴内容以 `\x1b[M`+0-2 字符结束时被消耗。
//
// 已知限制:这计算 JS 字符串字符,但 X10 是字节导向的stdin 使用
// utf8 编码App.tsx。在列 162-191 × 行 96-159两个坐标字节
//0xC2-0xDF, 0x80-0xBF形成有效的 UTF-8 2 字节序列并折叠为一个字符
// — 长度检查失败,事件缓冲直到下一个按键吸收它。
// 修复需要 latin1 stdinX10 的 223 坐标限制正是 SGR 被发明的原因,
// 没有 SGR 的终端在 162+ 列上很少见。
if (
x10Mouse &&
code === 0x4d /* M */ &&
i - seqStart === 2 &&
(i + 1 >= data.length || data.charCodeAt(i + 1) >= 0x20) &&
(i + 2 >= data.length || data.charCodeAt(i + 2) >= 0x20) &&
(i + 3 >= data.length || data.charCodeAt(i + 3) >= 0x20)
) {
if (i + 4 <= data.length) {
i += 4
emitSequence(data.slice(seqStart, i))
} else {
// 不完整 — 退出循环;从 seqStart 缓冲结束输入。
// 重新进入通过无效 CSI 回退从 ground 重新分词。
i = data.length
}
break
}
if (isCSIFinal(code)) {
i++
emitSequence(data.slice(seqStart, i))
} else if (isCSIParam(code) || isCSIIntermediate(code)) {
i++
} else {
// 无效 CSI - 中止,视为文本
result.state = 'ground'
textStart = seqStart
}
break
case 'ss3':
// SS3 序列ESC O 后跟一个最终字节
if (code >= 0x40 && code <= 0x7e) {
i++
emitSequence(data.slice(seqStart, i))
} else {
// 无效 - 视为文本
result.state = 'ground'
textStart = seqStart
}
break
case 'osc':
if (code === C0.BEL) {
i++
emitSequence(data.slice(seqStart, i))
} else if (
code === C0.ESC &&
i + 1 < data.length &&
data.charCodeAt(i + 1) === ESC_TYPE.ST
) {
i += 2
emitSequence(data.slice(seqStart, i))
} else {
i++
}
break
case 'dcs':
case 'apc':
if (code === C0.BEL) {
i++
emitSequence(data.slice(seqStart, i))
} else if (
code === C0.ESC &&
i + 1 < data.length &&
data.charCodeAt(i + 1) === ESC_TYPE.ST
) {
i += 2
emitSequence(data.slice(seqStart, i))
} else {
i++
}
break
}
}
// 处理输入结束
if (result.state === 'ground') {
flushText()
} else if (flush) {
// 强制输出不完整序列
const remaining = data.slice(seqStart)
if (remaining) tokens.push({ type: 'sequence', value: remaining })
result.state = 'ground'
} else {
// 缓冲不完整序列供下次调用
result.buffer = data.slice(seqStart)
}
return { tokens, state: result }
}

View File

@@ -0,0 +1,236 @@
/**
* ANSI 解析器 - 语义类型
*
* 这些类型表示 ANSI 转义序列的语义含义,而不是它们的字符串表示。
* 灵感来自 ghostty 的基于动作的设计。
*/
// =============================================================================
// 颜色
// =============================================================================
/** 16 色 palette 的命名颜色 */
export type NamedColor =
| 'black'
| 'red'
| 'green'
| 'yellow'
| 'blue'
| 'magenta'
| 'cyan'
| 'white'
| 'brightBlack'
| 'brightRed'
| 'brightGreen'
| 'brightYellow'
| 'brightBlue'
| 'brightMagenta'
| 'brightCyan'
| 'brightWhite'
/** 颜色规范 - 可以是命名、索引256或 RGB */
export type Color =
| { type: 'named'; name: NamedColor }
| { type: 'indexed'; index: number } // 0-255
| { type: 'rgb'; r: number; g: number; b: number }
| { type: 'default' }
// =============================================================================
// 文本样式
// =============================================================================
/** 下划线样式变体 */
export type UnderlineStyle =
| 'none'
| 'single'
| 'double'
| 'curly'
| 'dotted'
| 'dashed'
/** 文本样式属性 - 表示当前样式状态 */
export type TextStyle = {
bold: boolean
dim: boolean
italic: boolean
underline: UnderlineStyle
blink: boolean
inverse: boolean
hidden: boolean
strikethrough: boolean
overline: boolean
fg: Color
bg: Color
underlineColor: Color
}
/** 创建默认(重置)文本样式 */
export function defaultStyle(): TextStyle {
return {
bold: false,
dim: false,
italic: false,
underline: 'none',
blink: false,
inverse: false,
hidden: false,
strikethrough: false,
overline: false,
fg: { type: 'default' },
bg: { type: 'default' },
underlineColor: { type: 'default' },
}
}
/** 检查两个样式是否相等 */
export function stylesEqual(a: TextStyle, b: TextStyle): boolean {
return (
a.bold === b.bold &&
a.dim === b.dim &&
a.italic === b.italic &&
a.underline === b.underline &&
a.blink === b.blink &&
a.inverse === b.inverse &&
a.hidden === b.hidden &&
a.strikethrough === b.strikethrough &&
a.overline === b.overline &&
colorsEqual(a.fg, b.fg) &&
colorsEqual(a.bg, b.bg) &&
colorsEqual(a.underlineColor, b.underlineColor)
)
}
/** 检查两种颜色是否相等 */
export function colorsEqual(a: Color, b: Color): boolean {
if (a.type !== b.type) return false
switch (a.type) {
case 'named':
return a.name === (b as typeof a).name
case 'indexed':
return a.index === (b as typeof a).index
case 'rgb':
return (
a.r === (b as typeof a).r &&
a.g === (b as typeof a).g &&
a.b === (b as typeof a).b
)
case 'default':
return true
}
}
// =============================================================================
// 光标动作
// =============================================================================
export type CursorDirection = 'up' | 'down' | 'forward' | 'back'
export type CursorAction =
| { type: 'move'; direction: CursorDirection; count: number }
| { type: 'position'; row: number; col: number }
| { type: 'column'; col: number }
| { type: 'row'; row: number }
| { type: 'save' }
| { type: 'restore' }
| { type: 'show' }
| { type: 'hide' }
| {
type: 'style'
style: 'block' | 'underline' | 'bar'
blinking: boolean
}
| { type: 'nextLine'; count: number }
| { type: 'prevLine'; count: number }
// =============================================================================
// 擦除动作
// =============================================================================
export type EraseAction =
| { type: 'display'; region: 'toEnd' | 'toStart' | 'all' | 'scrollback' }
| { type: 'line'; region: 'toEnd' | 'toStart' | 'all' }
| { type: 'chars'; count: number }
// =============================================================================
// 滚动动作
// =============================================================================
export type ScrollAction =
| { type: 'up'; count: number }
| { type: 'down'; count: number }
| { type: 'setRegion'; top: number; bottom: number }
// =============================================================================
// 模式动作
// =============================================================================
export type ModeAction =
| { type: 'alternateScreen'; enabled: boolean }
| { type: 'bracketedPaste'; enabled: boolean }
| { type: 'mouseTracking'; mode: 'off' | 'normal' | 'button' | 'any' }
| { type: 'focusEvents'; enabled: boolean }
// =============================================================================
// 链接动作OSC 8
// =============================================================================
export type LinkAction =
| { type: 'start'; url: string; params?: Record<string, string> }
| { type: 'end' }
// =============================================================================
// 标题动作OSC 0/1/2
// =============================================================================
export type TitleAction =
| { type: 'windowTitle'; title: string }
| { type: 'iconName'; name: string }
| { type: 'both'; title: string }
// =============================================================================
// 标签页状态动作OSC 21337
// =============================================================================
/**
* 每个标签页的 chrome 元数据。每个字段有三种状态:
* - 属性缺席 → 序列中未提及,无变化
* - null → 显式清除(裸 key 或带空值的 key=
* - value → 设置为此值
*/
export type TabStatusAction = {
indicator?: Color | null
status?: string | null
statusColor?: Color | null
}
// =============================================================================
// 解析段 - 解析器的输出
// =============================================================================
/** 样式文本段 */
export type TextSegment = {
type: 'text'
text: string
style: TextStyle
}
/** 具有宽度信息的字素(视觉字符单元) */
export type Grapheme = {
value: string
width: 1 | 2 // 显示宽度(列)
}
/** 所有可能的解析动作 */
export type Action =
| { type: 'text'; graphemes: Grapheme[]; style: TextStyle }
| { type: 'cursor'; action: CursorAction }
| { type: 'erase'; action: EraseAction }
| { type: 'scroll'; action: ScrollAction }
| { type: 'mode'; action: ModeAction }
| { type: 'link'; action: LinkAction }
| { type: 'title'; action: TitleAction }
| { type: 'tabStatus'; action: TabStatusAction }
| { type: 'sgr'; params: string } // 选择图形呈现(样式变更)
| { type: 'bell' }
| { type: 'reset' } // 完全终端重置ESC c
| { type: 'unknown'; sequence: string } // 无法识别的序列

View File

@@ -0,0 +1,130 @@
import { createContext, useCallback, useContext, useMemo } from 'react'
import { isProgressReportingAvailable, type Progress } from './terminal.js'
import { BEL } from './termio/ansi.js'
import { ITERM2, OSC, osc, PROGRESS, wrapForMultiplexer } from './termio/osc.js'
type WriteRaw = (data: string) => void
export const TerminalWriteContext = createContext<WriteRaw | null>(null)
export const TerminalWriteProvider = TerminalWriteContext.Provider
export type TerminalNotification = {
notifyITerm2: (opts: { message: string; title?: string }) => void
notifyKitty: (opts: { message: string; title: string; id: number }) => void
notifyGhostty: (opts: { message: string; title: string }) => void
notifyBell: () => void
/**
* 通过 OSC 9;4 序列向终端报告进度。
* 支持的终端ConEmu、Ghostty 1.2.0+、iTerm2 3.6.6+
* 传递 state=null 以清除进度。
*/
progress: (state: Progress['state'] | null, percentage?: number) => void
}
/**
* 用于终端通知的 Hook。
* 提供向各种终端发送通知的方法。
*/
export function useTerminalNotification(): TerminalNotification {
const writeRaw = useContext(TerminalWriteContext)
if (!writeRaw) {
throw new Error(
'useTerminalNotification must be used within TerminalWriteProvider',
)
}
const notifyITerm2 = useCallback(
({ message, title }: { message: string; title?: string }) => {
const displayString = title ? `${title}:\n${message}` : message
writeRaw(wrapForMultiplexer(osc(OSC.ITERM2, `\n\n${displayString}`)))
},
[writeRaw],
)
const notifyKitty = useCallback(
({
message,
title,
id,
}: {
message: string
title: string
id: number
}) => {
writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:d=0:p=title`, title)))
writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:p=body`, message)))
writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:d=1:a=focus`, '')))
},
[writeRaw],
)
const notifyGhostty = useCallback(
({ message, title }: { message: string; title: string }) => {
writeRaw(wrapForMultiplexer(osc(OSC.GHOSTTY, 'notify', title, message)))
},
[writeRaw],
)
const notifyBell = useCallback(() => {
// 原始 BEL——在 tmux 内这会触发 tmux 的 bell-action窗口标志
// 包装会使它成为不透明的 DCS 负载并失去该回退。
writeRaw(BEL)
}, [writeRaw])
const progress = useCallback(
(state: Progress['state'] | null, percentage?: number) => {
if (!isProgressReportingAvailable()) {
return
}
if (!state) {
writeRaw(
wrapForMultiplexer(
osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.CLEAR, ''),
),
)
return
}
const pct = Math.max(0, Math.min(100, Math.round(percentage ?? 0)))
switch (state) {
case 'completed':
writeRaw(
wrapForMultiplexer(
osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.CLEAR, ''),
),
)
break
case 'error':
writeRaw(
wrapForMultiplexer(
osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.ERROR, pct),
),
)
break
case 'indeterminate':
writeRaw(
wrapForMultiplexer(
osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.INDETERMINATE, ''),
),
)
break
case 'running':
writeRaw(
wrapForMultiplexer(
osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.SET, pct),
),
)
break
case null:
// 由上面的 if guard 处理
break
}
},
[writeRaw],
)
return useMemo(
() => ({ notifyITerm2, notifyKitty, notifyGhostty, notifyBell, progress }),
[notifyITerm2, notifyKitty, notifyGhostty, notifyBell, progress],
)
}

View File

@@ -0,0 +1,13 @@
import { logForDebugging } from '../utils/debug.js'
/**
* 如果值不是整数则记录警告。
* 用于开发时的类型检查。
*/
export function ifNotInteger(value: number | undefined, name: string): void {
if (value === undefined) return
if (Number.isInteger(value)) return
logForDebugging(`${name} should be an integer, got ${value}`, {
level: 'warn',
})
}

View File

@@ -0,0 +1,23 @@
import { lineWidth } from './line-width-cache.js'
/**
* 返回字符串中最宽一行的宽度。
* 用于确定多行字符串在终端中需要多少水平空间。
*/
export function widestLine(string: string): number {
let maxWidth = 0
let start = 0
while (start <= string.length) {
const end = string.indexOf('\n', start)
const line =
end === -1 ? string.substring(start) : string.substring(start, end)
maxWidth = Math.max(maxWidth, lineWidth(line))
if (end === -1) break
start = end + 1
}
return maxWidth
}

View File

@@ -0,0 +1,74 @@
import sliceAnsi from '../utils/sliceAnsi.js'
import { stringWidth } from './stringWidth.js'
import type { Styles } from './styles.js'
import { wrapAnsi } from './wrapAnsi.js'
const ELLIPSIS = '…'
// sliceAnsi 可能包含跨越边界的多宽字符(例如 CJK 在位置
// end-1 宽度 2 超出 1。重试一次更紧的边界。
function sliceFit(text: string, start: number, end: number): string {
const s = sliceAnsi(text, start, end)
return stringWidth(s) > end - start ? sliceAnsi(text, start, end - 1) : s
}
function truncate(
text: string,
columns: number,
position: 'start' | 'middle' | 'end',
): string {
if (columns < 1) return ''
if (columns === 1) return ELLIPSIS
const length = stringWidth(text)
if (length <= columns) return text
if (position === 'start') {
return ELLIPSIS + sliceFit(text, length - columns + 1, length)
}
if (position === 'middle') {
const half = Math.floor(columns / 2)
return (
sliceFit(text, 0, half) +
ELLIPSIS +
sliceFit(text, length - (columns - half) + 1, length)
)
}
return sliceFit(text, 0, columns - 1) + ELLIPSIS
}
export default function wrapText(
text: string,
maxWidth: number,
wrapType: Styles['textWrap'],
): string {
if (wrapType === 'wrap') {
return wrapAnsi(text, maxWidth, {
trim: false,
hard: true,
})
}
if (wrapType === 'wrap-trim') {
return wrapAnsi(text, maxWidth, {
trim: true,
hard: true,
})
}
if (wrapType!.startsWith('truncate')) {
let position: 'end' | 'middle' | 'start' = 'end'
if (wrapType === 'truncate-middle') {
position = 'middle'
}
if (wrapType === 'truncate-start') {
position = 'start'
}
return truncate(text, maxWidth, position)
}
return text
}

View File

@@ -0,0 +1,24 @@
import wrapAnsiNpm from 'wrap-ansi'
type WrapAnsiOptions = {
hard?: boolean
wordWrap?: boolean
trim?: boolean
}
// 优先使用 Bun 内置的 wrapAnsi如果可用
const wrapAnsiBun =
typeof Bun !== 'undefined' && typeof Bun.wrapAnsi === 'function'
? Bun.wrapAnsi
: null
/**
* 封装 wrap-ansi 库,支持 Bun 原生实现以获得更好的性能。
*/
const wrapAnsi: (
input: string,
columns: number,
options?: WrapAnsiOptions,
) => string = wrapAnsiBun ?? wrapAnsiNpm
export { wrapAnsi }