first commit
This commit is contained in:
26
terminal/packages/terminal-core/package.json
Normal file
26
terminal/packages/terminal-core/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@remoteconn/terminal-core",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"miniprogram": "dist-miniprogram/index.cjs",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"build:miniprogram": "tsc -p tsconfig.json --module commonjs --moduleResolution node --outDir dist-miniprogram --declaration false --sourceMap false",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"typecheck:strict": "tsc -p tsconfig.json --lib esnext --noEmit",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"test": "../../node_modules/.bin/vitest run --passWithNoTests"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.3.3",
|
||||
"eslint": "^10.0.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
67
terminal/packages/terminal-core/src/index.ts
Normal file
67
terminal/packages/terminal-core/src/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @remoteconn/terminal-core
|
||||
* 零 DOM 跨平台终端核心库 — 供 Web/iOS/微信小程序三端共用。
|
||||
*/
|
||||
|
||||
// ── 基础类型 ──────────────────────────────────────────────────────────────────
|
||||
export type {
|
||||
SessionState,
|
||||
ConnectParams,
|
||||
TerminalCredential,
|
||||
FrameMeta,
|
||||
TransportEvent,
|
||||
TransportEventListener,
|
||||
ColorValue,
|
||||
CharAttribs,
|
||||
TerminalCell,
|
||||
TerminalLine,
|
||||
CursorState,
|
||||
TerminalSnapshot,
|
||||
} from "./types";
|
||||
|
||||
export {
|
||||
DEFAULT_FG,
|
||||
DEFAULT_BG,
|
||||
DEFAULT_COLOR,
|
||||
FLAG_BOLD,
|
||||
FLAG_DIM,
|
||||
FLAG_ITALIC,
|
||||
FLAG_UNDERLINE,
|
||||
FLAG_BLINK,
|
||||
FLAG_INVERSE,
|
||||
FLAG_INVISIBLE,
|
||||
FLAG_STRIKETHROUGH,
|
||||
FLAG_OVERLINE,
|
||||
makeDefaultAttribs,
|
||||
copyAttribs,
|
||||
makeBlankCell,
|
||||
} from "./types";
|
||||
|
||||
// ── 接口 ──────────────────────────────────────────────────────────────────────
|
||||
export type { TerminalTransport } from "./transport/terminalTransport";
|
||||
export type { RendererAdapter } from "./renderer/rendererAdapter";
|
||||
export type { IInputSource, InputEventMap, KeyPayload, InputPayload, PastePayload, CompositionPayload } from "./input/IInputSource";
|
||||
export type { IMeasureAdapter } from "./layout/IMeasureAdapter";
|
||||
|
||||
// ── Session ───────────────────────────────────────────────────────────────────
|
||||
export { SessionMachine, canTransition, assertTransition } from "./session/sessionMachine";
|
||||
|
||||
// ── Core ──────────────────────────────────────────────────────────────────────
|
||||
export { TerminalCore } from "./renderer/terminalCore";
|
||||
export { OutputBuffer } from "./renderer/outputBuffer";
|
||||
export type { OutputBufferOptions } from "./renderer/outputBuffer";
|
||||
|
||||
// ── Input ─────────────────────────────────────────────────────────────────────
|
||||
export { InputBridge } from "./input/inputBridge";
|
||||
export type { InputBridgeOptions, CursorKeyMode } from "./input/inputBridge";
|
||||
export { ImeController } from "./input/imeController";
|
||||
export type { ImeState } from "./input/imeController";
|
||||
|
||||
// ── Layout ────────────────────────────────────────────────────────────────────
|
||||
export { calcSize, sizeChanged } from "./layout/sizeCalculator";
|
||||
export type { SizeResult } from "./layout/sizeCalculator";
|
||||
export { toGlobalRow, isInBand, textCursorPos, charDisplayWidth } from "./layout/cursorMath";
|
||||
export type { CursorPos } from "./layout/cursorMath";
|
||||
|
||||
// ── Sanitize ──────────────────────────────────────────────────────────────────
|
||||
export { sanitizeTerminalOutput } from "./sanitize/terminalSanitizer";
|
||||
42
terminal/packages/terminal-core/src/input/IInputSource.ts
Normal file
42
terminal/packages/terminal-core/src/input/IInputSource.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* IInputSource — 平台输入事件源抽象接口(Web DOM / 小程序 bindinput)。
|
||||
* 所有实现在 apps/* 中,packages/terminal-core 只定义接口。
|
||||
*/
|
||||
|
||||
export interface KeyPayload {
|
||||
key: string;
|
||||
code: string;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
shiftKey: boolean;
|
||||
metaKey: boolean;
|
||||
isComposing: boolean;
|
||||
}
|
||||
|
||||
export interface InputPayload {
|
||||
data: string;
|
||||
isComposing: boolean;
|
||||
}
|
||||
|
||||
export interface PastePayload {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface CompositionPayload {
|
||||
data: string;
|
||||
}
|
||||
|
||||
export type InputEventMap = {
|
||||
key: KeyPayload;
|
||||
input: InputPayload;
|
||||
paste: PastePayload;
|
||||
compositionstart: CompositionPayload;
|
||||
compositionend: CompositionPayload;
|
||||
};
|
||||
|
||||
export interface IInputSource {
|
||||
on<K extends keyof InputEventMap>(
|
||||
event: K,
|
||||
cb: (payload: InputEventMap[K]) => void
|
||||
): () => void;
|
||||
}
|
||||
81
terminal/packages/terminal-core/src/input/imeController.ts
Normal file
81
terminal/packages/terminal-core/src/input/imeController.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* ImeController — IME 输入法状态机(纯逻辑,零 DOM 事件依赖)。
|
||||
*
|
||||
* 状态:
|
||||
* idle → composing(compositionstart)
|
||||
* composing → commit_pending(compositionend)
|
||||
* commit_pending → idle(提交完成或超时)
|
||||
*
|
||||
* 平台层(Web/小程序)负责订阅 compositionstart/end 后调用此 controller 的方法。
|
||||
*/
|
||||
|
||||
export type ImeState = "idle" | "composing" | "commit_pending";
|
||||
|
||||
export class ImeController {
|
||||
private _state: ImeState = "idle";
|
||||
/** 当前组合字符串 */
|
||||
private _composingData = "";
|
||||
/** 防止 compositionend 后对应的 input 事件重复提交的保护窗口(ms) */
|
||||
private _commitGuardUntil = 0;
|
||||
/** 超时守卫 timer(无 DOM API — 由平台层通过注入的 setTimeoutFn 驱动) */
|
||||
private _guardTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly setTimeoutFn: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>,
|
||||
private readonly clearTimeoutFn: (id: ReturnType<typeof setTimeout>) => void,
|
||||
/** 超时自动复位(ms),默认 2000 */
|
||||
private readonly timeoutMs = 2000
|
||||
) {}
|
||||
|
||||
get state(): ImeState { return this._state; }
|
||||
get composingData(): string { return this._composingData; }
|
||||
get isComposing(): boolean { return this._state !== "idle"; }
|
||||
|
||||
onCompositionStart(data: string): void {
|
||||
this._clearGuard();
|
||||
this._state = "composing";
|
||||
this._composingData = data;
|
||||
// 安全超时:若 compositionend 迟迟不来,自动复位
|
||||
this._guardTimer = this.setTimeoutFn(() => {
|
||||
if (this._state !== "idle") {
|
||||
this._state = "idle";
|
||||
this._composingData = "";
|
||||
}
|
||||
}, this.timeoutMs);
|
||||
}
|
||||
|
||||
onCompositionEnd(data: string): string {
|
||||
this._clearGuard();
|
||||
this._state = "commit_pending";
|
||||
this._composingData = data;
|
||||
// 设置提交保护窗口(防 input 事件重复触发)
|
||||
this._commitGuardUntil = Date.now() + 50;
|
||||
const committed = data;
|
||||
this._state = "idle";
|
||||
this._composingData = "";
|
||||
return committed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前 input 事件是否应被 IME 消耗(不应单独发送给 Transport)。
|
||||
*/
|
||||
shouldConsumeInputEvent(): boolean {
|
||||
if (this._state === "composing") return true;
|
||||
if (Date.now() < this._commitGuardUntil) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._clearGuard();
|
||||
this._state = "idle";
|
||||
this._composingData = "";
|
||||
this._commitGuardUntil = 0;
|
||||
}
|
||||
|
||||
private _clearGuard(): void {
|
||||
if (this._guardTimer !== null) {
|
||||
this.clearTimeoutFn(this._guardTimer);
|
||||
this._guardTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { InputBridge } from "./inputBridge";
|
||||
|
||||
describe("InputBridge.mapPaste", () => {
|
||||
it("统一换行为 LF,避免将多行粘贴转换为 CR", () => {
|
||||
const bridge = new InputBridge({ bracketedPaste: false });
|
||||
const input = "line1\r\nline2\nline3\rline4";
|
||||
expect(bridge.mapPaste(input)).toBe("line1\nline2\nline3\nline4");
|
||||
});
|
||||
|
||||
it("开启 bracketed paste 时包裹 ESC[200~/ESC[201~", () => {
|
||||
const bridge = new InputBridge({ bracketedPaste: true });
|
||||
expect(bridge.mapPaste("a\r\nb")).toBe("\x1b[200~a\nb\x1b[201~");
|
||||
});
|
||||
});
|
||||
173
terminal/packages/terminal-core/src/input/inputBridge.ts
Normal file
173
terminal/packages/terminal-core/src/input/inputBridge.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* InputBridge — 键序列映射纯逻辑(零 DOM 事件依赖)。
|
||||
*
|
||||
* 负责将逻辑按键/文本映射为发送给 Transport 的 VT 字节串。
|
||||
* 对齐 xterm.js@5.3.0 src/common/input/Keyboard.ts 行为。
|
||||
*/
|
||||
|
||||
/** 应用光标键模式(DECCKM ?1h 激活时使用 SS3 序列) */
|
||||
export type CursorKeyMode = "normal" | "application";
|
||||
/** bracketed paste 模式 */
|
||||
export type BracketedPasteMode = boolean;
|
||||
|
||||
export interface InputBridgeOptions {
|
||||
cursorKeyMode?: CursorKeyMode;
|
||||
bracketedPaste?: BracketedPasteMode;
|
||||
}
|
||||
|
||||
export class InputBridge {
|
||||
private cursorKeyMode: CursorKeyMode;
|
||||
private bracketedPaste: BracketedPasteMode;
|
||||
|
||||
constructor(opts: InputBridgeOptions = {}) {
|
||||
this.cursorKeyMode = opts.cursorKeyMode ?? "normal";
|
||||
this.bracketedPaste = opts.bracketedPaste ?? false;
|
||||
}
|
||||
|
||||
setCursorKeyMode(mode: CursorKeyMode): void { this.cursorKeyMode = mode; }
|
||||
setBracketedPaste(enabled: boolean): void { this.bracketedPaste = enabled; }
|
||||
|
||||
/**
|
||||
* 将 KeyboardEvent 语义映射为 VT 字节串。
|
||||
* 返回 null 表示该按键不产生终端输入(如单纯 modifier key)。
|
||||
*/
|
||||
mapKey(
|
||||
key: string,
|
||||
code: string,
|
||||
ctrlKey: boolean,
|
||||
altKey: boolean,
|
||||
shiftKey: boolean,
|
||||
metaKey: boolean
|
||||
): string | null {
|
||||
// --- ASCII 控制字符 ---
|
||||
if (ctrlKey && !altKey && !metaKey) {
|
||||
const ctrl = mapCtrlKey(key, code, shiftKey);
|
||||
if (ctrl !== null) return ctrl;
|
||||
}
|
||||
|
||||
// --- 功能键 ---
|
||||
const fn = mapFunctionKey(key, code, shiftKey, ctrlKey, altKey, this.cursorKeyMode);
|
||||
if (fn !== null) return fn;
|
||||
|
||||
// --- Alt/Meta 前缀 ---
|
||||
if ((altKey || metaKey) && key.length === 1) {
|
||||
return `\x1b${key}`;
|
||||
}
|
||||
|
||||
// --- 普通可打印字符 ---
|
||||
if (key.length > 0 && !key.startsWith("Dead") && key !== "Unidentified") {
|
||||
// 过滤掉单纯的 modifier key 名称
|
||||
if (MODIFIER_KEY_NAMES.has(key)) return null;
|
||||
return key;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将粘贴文本处理为终端输入字节串:
|
||||
* - 行结束统一归一为 LF(避免多行粘贴被当作多次回车)
|
||||
* - bracketed paste 模式下用 ESC[200~ / ESC[201~ 包裹
|
||||
*/
|
||||
mapPaste(text: string): string {
|
||||
// 统一处理 CRLF / CR / LF,确保多平台粘贴行为一致。
|
||||
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
if (this.bracketedPaste) {
|
||||
return `\x1b[200~${normalized}\x1b[201~`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 内部工具 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const MODIFIER_KEY_NAMES = new Set([
|
||||
"Control", "Shift", "Alt", "Meta",
|
||||
"CapsLock", "NumLock", "ScrollLock",
|
||||
"OS", "Win",
|
||||
]);
|
||||
|
||||
function mapCtrlKey(key: string, _code: string, shiftKey: boolean): string | null {
|
||||
// CTRL + A-Z → 0x01-0x1A
|
||||
if (key.length === 1) {
|
||||
const upper = key.toUpperCase();
|
||||
const cp = upper.charCodeAt(0);
|
||||
if (cp >= 65 && cp <= 90) {
|
||||
return String.fromCharCode(cp - 64);
|
||||
}
|
||||
}
|
||||
// 特殊组合
|
||||
switch (key) {
|
||||
case "@": return "\x00";
|
||||
case "[": return "\x1b";
|
||||
case "\\": return "\x1c";
|
||||
case "]": return "\x1d";
|
||||
case "^": return "\x1e";
|
||||
case "_": return "\x1f";
|
||||
case " ": return "\x00";
|
||||
case "Enter": return shiftKey ? "\n" : "\r";
|
||||
case "Backspace": return "\x08";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapFunctionKey(
|
||||
key: string,
|
||||
_code: string,
|
||||
shiftKey: boolean,
|
||||
ctrlKey: boolean,
|
||||
_altKey: boolean,
|
||||
cursorKeyMode: CursorKeyMode
|
||||
): string | null {
|
||||
const app = cursorKeyMode === "application";
|
||||
|
||||
switch (key) {
|
||||
// --- 光标键 ---
|
||||
case "ArrowUp": return modifyArrow(app ? "\x1bOA" : "\x1b[A", shiftKey, ctrlKey);
|
||||
case "ArrowDown": return modifyArrow(app ? "\x1bOB" : "\x1b[B", shiftKey, ctrlKey);
|
||||
case "ArrowRight": return modifyArrow(app ? "\x1bOC" : "\x1b[C", shiftKey, ctrlKey);
|
||||
case "ArrowLeft": return modifyArrow(app ? "\x1bOD" : "\x1b[D", shiftKey, ctrlKey);
|
||||
|
||||
// --- Enter / Backspace / Tab ---
|
||||
case "Enter": return "\r";
|
||||
case "Backspace": return ctrlKey ? "\x08" : "\x7f";
|
||||
case "Tab": return shiftKey ? "\x1b[Z" : "\t";
|
||||
case "Escape": return "\x1b";
|
||||
case "Delete": return "\x1b[3~";
|
||||
case "Insert": return "\x1b[2~";
|
||||
|
||||
// --- Home / End ---
|
||||
case "Home": return ctrlKey ? "\x1b[1;5H" : app ? "\x1bOH" : "\x1b[H";
|
||||
case "End": return ctrlKey ? "\x1b[1;5F" : app ? "\x1bOF" : "\x1b[F";
|
||||
|
||||
// --- Page Up / Page Down ---
|
||||
case "PageUp": return shiftKey ? "\x1b[5;2~" : "\x1b[5~";
|
||||
case "PageDown": return shiftKey ? "\x1b[6;2~" : "\x1b[6~";
|
||||
|
||||
// --- F1-F12 ---
|
||||
case "F1": return "\x1bOP";
|
||||
case "F2": return "\x1bOQ";
|
||||
case "F3": return "\x1bOR";
|
||||
case "F4": return "\x1bOS";
|
||||
case "F5": return "\x1b[15~";
|
||||
case "F6": return "\x1b[17~";
|
||||
case "F7": return "\x1b[18~";
|
||||
case "F8": return "\x1b[19~";
|
||||
case "F9": return "\x1b[20~";
|
||||
case "F10": return "\x1b[21~";
|
||||
case "F11": return "\x1b[23~";
|
||||
case "F12": return "\x1b[24~";
|
||||
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function modifyArrow(base: string, shiftKey: boolean, ctrlKey: boolean): string {
|
||||
if (ctrlKey && shiftKey) {
|
||||
// replace final letter with modifier param
|
||||
return base.slice(0, -1) + ";6" + base.slice(-1);
|
||||
}
|
||||
if (ctrlKey) return base.slice(0, -1) + ";5" + base.slice(-1);
|
||||
if (shiftKey) return base.slice(0, -1) + ";2" + base.slice(-1);
|
||||
return base;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* IMeasureAdapter — 平台测量接口(字符/容器尺寸获取)。
|
||||
* Web 由 ResizeObserver + Canvas 实现;小程序由 wx.createSelectorQuery 实现。
|
||||
*/
|
||||
export interface IMeasureAdapter {
|
||||
/** 测量单个等宽字符宽/高(像素)*/
|
||||
measureChar(): { widthPx: number; heightPx: number };
|
||||
|
||||
/** 测量终端容器内部可用宽/高(已去除 padding,像素)*/
|
||||
measureContainer(): { widthPx: number; heightPx: number };
|
||||
|
||||
/** 订阅容器尺寸变化;返回取消订阅函数 */
|
||||
onResize(cb: () => void): () => void;
|
||||
}
|
||||
80
terminal/packages/terminal-core/src/layout/cursorMath.ts
Normal file
80
terminal/packages/terminal-core/src/layout/cursorMath.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* cursorMath — 光标坐标纯算法工具。
|
||||
* 零 DOM 依赖。区分"终端光标"(VT core 语义)与"输入光标"(输入框 selectionStart)。
|
||||
*/
|
||||
|
||||
export interface CursorPos { row: number; col: number; }
|
||||
|
||||
/** 将终端光标视口坐标转为全局行号 */
|
||||
export function toGlobalRow(baseY: number, cursorY: number): number {
|
||||
return baseY + cursorY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断触摸行是否在光标激活带内。
|
||||
* activationRadius 建议 2 行。
|
||||
*/
|
||||
export function isInBand(touchRow: number, cursorGlobalRow: number, activationRadius = 2): boolean {
|
||||
return Math.abs(touchRow - cursorGlobalRow) <= activationRadius;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 textarea 文本推导输入框光标位置((row, col) 坐标,0-based)。
|
||||
* 纯函数,O(n) 扫描文本。
|
||||
*/
|
||||
export function textCursorPos(
|
||||
text: string,
|
||||
selectionStart: number,
|
||||
cols: number
|
||||
): CursorPos {
|
||||
let row = 0;
|
||||
let col = 0;
|
||||
for (let i = 0; i < selectionStart && i < text.length; i++) {
|
||||
const ch = text[i] ?? "";
|
||||
if (ch === "\n") {
|
||||
row++;
|
||||
col = 0;
|
||||
} else {
|
||||
const w = charDisplayWidth(ch);
|
||||
col += w;
|
||||
if (col >= cols) {
|
||||
// 自动折行
|
||||
row += Math.floor(col / cols);
|
||||
col = col % cols;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { row, col };
|
||||
}
|
||||
|
||||
/**
|
||||
* 字符显示宽度(ASCII=1, CJK/全角=2, 控制字符=0)。
|
||||
* 使用简化的 East Asian Width 判断;完整版可插入 unicode-east-asian-width 库。
|
||||
*/
|
||||
export function charDisplayWidth(ch: string): 0 | 1 | 2 {
|
||||
if (!ch) return 0;
|
||||
const cp = ch.codePointAt(0) ?? 0;
|
||||
if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0; // 控制字符
|
||||
if (isWide(cp)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function isWide(cp: number): boolean {
|
||||
// GB2312/CJK 基本覆盖范围
|
||||
if (cp >= 0x1100 && cp <= 0x115F) return true; // Hangul Jamo
|
||||
if (cp === 0x2329 || cp === 0x232A) return true;
|
||||
if (cp >= 0x2E80 && cp <= 0x303E) return true; // CJK Radicals
|
||||
if (cp >= 0x3040 && cp <= 0x33FF) return true; // Japanese
|
||||
if (cp >= 0x3400 && cp <= 0x4DBF) return true; // CJK Ext A
|
||||
if (cp >= 0x4E00 && cp <= 0x9FFF) return true; // CJK Unified
|
||||
if (cp >= 0xA000 && cp <= 0xA4CF) return true; // Yi
|
||||
if (cp >= 0xAC00 && cp <= 0xD7AF) return true; // Hangul Syllables
|
||||
if (cp >= 0xF900 && cp <= 0xFAFF) return true; // CJK Compatibility
|
||||
if (cp >= 0xFE10 && cp <= 0xFE19) return true; // Vertical forms
|
||||
if (cp >= 0xFE30 && cp <= 0xFE6F) return true; // CJK Compatibility Small
|
||||
if (cp >= 0xFF00 && cp <= 0xFF60) return true; // Fullwidth
|
||||
if (cp >= 0xFFE0 && cp <= 0xFFE6) return true;
|
||||
if (cp >= 0x1F300 && cp <= 0x1F9FF) return true; // Emoji
|
||||
if (cp >= 0x20000 && cp <= 0x2FFFD) return true; // CJK Ext B-F
|
||||
return false;
|
||||
}
|
||||
27
terminal/packages/terminal-core/src/layout/sizeCalculator.ts
Normal file
27
terminal/packages/terminal-core/src/layout/sizeCalculator.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* sizeCalculator — 纯算法:px → cols/rows。
|
||||
* 零 DOM 依赖,所有 DOM 测量由平台的 IMeasureAdapter 注入。
|
||||
*/
|
||||
|
||||
export interface SizeResult {
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export function calcSize(
|
||||
containerWidthPx: number,
|
||||
containerHeightPx: number,
|
||||
charWidthPx: number,
|
||||
lineHeightPx: number
|
||||
): SizeResult {
|
||||
const cols = Math.max(20, Math.floor(containerWidthPx / Math.max(1, charWidthPx)));
|
||||
const rows = Math.max(8, Math.floor(containerHeightPx / Math.max(1, lineHeightPx)));
|
||||
return { cols, rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断尺寸变化是否超过阈值(>= 1 col 或 >= 1 row),避免抖动触发频繁 resize。
|
||||
*/
|
||||
export function sizeChanged(a: SizeResult, b: SizeResult): boolean {
|
||||
return Math.abs(a.cols - b.cols) + Math.abs(a.rows - b.rows) >= 1;
|
||||
}
|
||||
75
terminal/packages/terminal-core/src/renderer/outputBuffer.ts
Normal file
75
terminal/packages/terminal-core/src/renderer/outputBuffer.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* OutputBuffer — 终端输出缓冲区。
|
||||
* 双阈值(条目数 + 字节数)裁剪,保证低端设备不 OOM。
|
||||
*/
|
||||
|
||||
export interface OutputBufferOptions {
|
||||
maxEntries?: number; // 默认 500
|
||||
maxBytes?: number; // 默认 512 * 1024 (512KB)
|
||||
}
|
||||
|
||||
export class OutputBuffer {
|
||||
private entries: string[] = [];
|
||||
private totalBytes = 0;
|
||||
private readonly maxEntries: number;
|
||||
private readonly maxBytes: number;
|
||||
|
||||
/** 缓冲区修订号:每次 push 后递增,供外部脏检测 */
|
||||
public revision = 0;
|
||||
|
||||
constructor(opts: OutputBufferOptions = {}) {
|
||||
this.maxEntries = Math.max(200, opts.maxEntries ?? 500);
|
||||
this.maxBytes = Math.max(65536, opts.maxBytes ?? 512 * 1024);
|
||||
}
|
||||
|
||||
push(data: string): void {
|
||||
if (data.length === 0) return;
|
||||
this.entries.push(data);
|
||||
this.totalBytes += data.length * 2; // UTF-16 字符 ≈ 2 字节估算
|
||||
|
||||
this.trim();
|
||||
this.revision++;
|
||||
}
|
||||
|
||||
/** 获取全部条目(用于重放) */
|
||||
getAll(): readonly string[] {
|
||||
return this.entries;
|
||||
}
|
||||
|
||||
/** 获取从指定修订号之后新增的条目(增量重放辅助) */
|
||||
getSince(revision: number): string {
|
||||
// 简化实现:revision 不足时全量重放
|
||||
if (revision <= 0) return this.entries.join("");
|
||||
// 粗略计算需要从后面取多少条目
|
||||
// 这里保守地返回全量;如需精确增量可增加 revision→index 映射表
|
||||
return this.entries.join("");
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries = [];
|
||||
this.totalBytes = 0;
|
||||
this.revision++;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.length;
|
||||
}
|
||||
|
||||
get bytes(): number {
|
||||
return this.totalBytes;
|
||||
}
|
||||
|
||||
private trim(): void {
|
||||
// 先按条目裁剪
|
||||
while (this.entries.length > this.maxEntries) {
|
||||
const removed = this.entries.shift();
|
||||
if (removed !== undefined) this.totalBytes -= removed.length * 2;
|
||||
}
|
||||
// 再按字节裁剪
|
||||
while (this.totalBytes > this.maxBytes && this.entries.length > 1) {
|
||||
const removed = this.entries.shift();
|
||||
if (removed !== undefined) this.totalBytes -= removed.length * 2;
|
||||
}
|
||||
if (this.totalBytes < 0) this.totalBytes = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { TerminalSnapshot } from "../types";
|
||||
|
||||
/**
|
||||
* RendererAdapter — 渲染层抽象接口(每个平台各自实现)。
|
||||
* 渲染器只读 snapshot,禁止回写 TerminalCore 内部状态。
|
||||
*/
|
||||
export interface RendererAdapter {
|
||||
/**
|
||||
* 挂载到平台容器。
|
||||
* Web: container 为 HTMLElement;
|
||||
* 小程序: 由 terminal-core-view 组件自行管理,传 null。
|
||||
*/
|
||||
mount(container: unknown): void;
|
||||
|
||||
/** 增量写入 VT 字节流(内部转发给 TerminalCore.write,触发重新渲染) */
|
||||
write(data: string): void;
|
||||
|
||||
/** 通知渲染器终端尺寸已变化 */
|
||||
resize(cols: number, rows: number): void;
|
||||
|
||||
/**
|
||||
* 全量重放快照(模式切换时调用,将渲染器状态同步到最新 snapshot)。
|
||||
*/
|
||||
applySnapshot(snapshot: TerminalSnapshot): void;
|
||||
|
||||
/** 销毁渲染器,释放 DOM/Canvas/定时器等资源 */
|
||||
dispose(): void;
|
||||
}
|
||||
1166
terminal/packages/terminal-core/src/renderer/terminalCore.ts
Normal file
1166
terminal/packages/terminal-core/src/renderer/terminalCore.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* TerminalSanitizer — 过滤 stdout/stderr 中会干扰渲染的噪音序列。
|
||||
*
|
||||
* 目前仅过滤同步更新模式(DECSM ?2026 = DECRQM-like sync-update),
|
||||
* 以及其他已知会在某些 SSH 服务端发送但 terminal-core 不处理的序列。
|
||||
* 不得过滤正常 CSI/OSC 序列,否则会破坏 TUI 程序显示。
|
||||
*/
|
||||
|
||||
/** 同步更新开始/结束序列(DEC private: ?2026h / ?2026l) */
|
||||
const SYNC_UPDATE_RE = /\x1b\[\?2026[hl]/g;
|
||||
/** Bracketed paste 开关(部分链路丢失 ESC 后会残留 '?2004h/l' 文本) */
|
||||
const BRACKETED_PASTE_RE = /(?:\x1b\[)?\?2004[hl]/g;
|
||||
|
||||
/** XTPUSHCOLORS / XTPOPCOLORS(Ps=10/11)—— terminal-core 不处理,安全丢弃 */
|
||||
const XTPUSH_POP_COLORS_RE = /\x1b\[(?:10|11)m/g;
|
||||
|
||||
export function sanitizeTerminalOutput(data: string): string {
|
||||
return data
|
||||
.replace(SYNC_UPDATE_RE, "")
|
||||
.replace(BRACKETED_PASTE_RE, "")
|
||||
.replace(XTPUSH_POP_COLORS_RE, "");
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { SessionState } from "../types";
|
||||
|
||||
/** 合法状态迁移表 */
|
||||
const TRANSITIONS: Record<SessionState, SessionState[]> = {
|
||||
idle: ["connecting", "disconnected"],
|
||||
connecting: ["auth_pending", "error", "disconnected"],
|
||||
auth_pending: ["connected", "error", "disconnected"],
|
||||
connected: ["reconnecting", "disconnected", "error"],
|
||||
reconnecting: ["connected", "error", "disconnected"],
|
||||
disconnected: ["connecting", "idle"],
|
||||
error: ["connecting", "disconnected"],
|
||||
};
|
||||
|
||||
export function canTransition(from: SessionState, to: SessionState): boolean {
|
||||
return (TRANSITIONS[from] ?? []).includes(to);
|
||||
}
|
||||
|
||||
export function assertTransition(from: SessionState, to: SessionState): void {
|
||||
if (!canTransition(from, to)) {
|
||||
throw new Error(`非法状态跳转: ${from} → ${to}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionMachine — 会话生命周期状态机。
|
||||
* 纯状态逻辑,无任何副作用,零 DOM 依赖。
|
||||
*/
|
||||
export class SessionMachine {
|
||||
private _state: SessionState = "idle";
|
||||
private listeners = new Set<(state: SessionState) => void>();
|
||||
|
||||
get state(): SessionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
transition(to: SessionState): void {
|
||||
assertTransition(this._state, to);
|
||||
this._state = to;
|
||||
for (const fn of this.listeners) {
|
||||
fn(this._state);
|
||||
}
|
||||
}
|
||||
|
||||
tryTransition(to: SessionState): boolean {
|
||||
if (!canTransition(this._state, to)) return false;
|
||||
this.transition(to);
|
||||
return true;
|
||||
}
|
||||
|
||||
onChange(fn: (state: SessionState) => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._state = "idle";
|
||||
for (const fn of this.listeners) {
|
||||
fn(this._state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ConnectParams, FrameMeta, SessionState, TransportEventListener } from "../types";
|
||||
|
||||
/**
|
||||
* TerminalTransport — 网络层抽象接口(三端各自实现)。
|
||||
* 此接口在 packages/terminal-core 定义;apps/web 现有实现兼容此签名。
|
||||
*/
|
||||
export interface TerminalTransport {
|
||||
connect(params: ConnectParams): Promise<void>;
|
||||
send(data: string, meta?: FrameMeta): Promise<void>;
|
||||
resize(cols: number, rows: number): Promise<void>;
|
||||
disconnect(reason?: string): Promise<void>;
|
||||
/** 返回取消订阅函数 */
|
||||
on(listener: TransportEventListener): () => void;
|
||||
getState(): SessionState;
|
||||
}
|
||||
149
terminal/packages/terminal-core/src/types.ts
Normal file
149
terminal/packages/terminal-core/src/types.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 所有平台共用的基础类型定义。
|
||||
* 此文件不得引入任何 DOM/BOM API,不得依赖 @remoteconn/shared 或 apps/* 模块。
|
||||
*/
|
||||
|
||||
// ── 会话状态 ───────────────────────────────────────────────────────────────────
|
||||
export type SessionState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "auth_pending"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "disconnected"
|
||||
| "error";
|
||||
|
||||
// ── 连接参数 ───────────────────────────────────────────────────────────────────
|
||||
export interface ConnectParams {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
credential: TerminalCredential;
|
||||
knownHostFingerprint?: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
clientSessionKey?: string;
|
||||
}
|
||||
|
||||
export type TerminalCredential =
|
||||
| { type: "password"; password: string }
|
||||
| { type: "privateKey"; privateKey: string; passphrase?: string }
|
||||
| { type: "certificate"; privateKey: string; certificate: string; passphrase?: string };
|
||||
|
||||
// ── 帧 meta(stdin 可选附加) ──────────────────────────────────────────────────
|
||||
export interface FrameMeta {
|
||||
source?: "keyboard" | "assist" | "paste";
|
||||
txnId?: string;
|
||||
}
|
||||
|
||||
// ── Transport 事件 ─────────────────────────────────────────────────────────────
|
||||
export type TransportEvent =
|
||||
| { type: "stdout"; data: string }
|
||||
| { type: "stderr"; data: string }
|
||||
| { type: "latency"; data: number }
|
||||
| { type: "connected"; fingerprint?: string }
|
||||
| { type: "disconnect"; reason: string }
|
||||
| { type: "error"; code: string; message: string };
|
||||
|
||||
export type TransportEventListener = (event: TransportEvent) => void;
|
||||
|
||||
// ── 颜色值 ────────────────────────────────────────────────────────────────────
|
||||
export interface ColorValue {
|
||||
mode: "default" | "p16" | "p256" | "rgb";
|
||||
/** p16: 0-15;p256: 0-255;rgb: 0xRRGGBB */
|
||||
value: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_FG: ColorValue = { mode: "default", value: 0 };
|
||||
export const DEFAULT_BG: ColorValue = { mode: "default", value: 0 };
|
||||
export const DEFAULT_COLOR: ColorValue = { mode: "default", value: 0 };
|
||||
|
||||
// ── SGR flags(bitmask) ───────────────────────────────────────────────────────
|
||||
export const FLAG_BOLD = 1 << 0;
|
||||
export const FLAG_DIM = 1 << 1;
|
||||
export const FLAG_ITALIC = 1 << 2;
|
||||
export const FLAG_UNDERLINE = 1 << 3;
|
||||
export const FLAG_BLINK = 1 << 4;
|
||||
export const FLAG_INVERSE = 1 << 5;
|
||||
export const FLAG_INVISIBLE = 1 << 6;
|
||||
export const FLAG_STRIKETHROUGH = 1 << 7;
|
||||
export const FLAG_OVERLINE = 1 << 8;
|
||||
|
||||
// ── 单元格 ────────────────────────────────────────────────────────────────────
|
||||
export interface TerminalCell {
|
||||
char: string; // 空单元格为 ' '
|
||||
width: 1 | 2; // CJK 宽字符占 2
|
||||
fg: ColorValue;
|
||||
bg: ColorValue;
|
||||
flags: number;
|
||||
underlineStyle: "none" | "single" | "double" | "curly" | "dotted" | "dashed";
|
||||
underlineColor: ColorValue;
|
||||
}
|
||||
|
||||
// ── 行 ────────────────────────────────────────────────────────────────────────
|
||||
export interface TerminalLine {
|
||||
cells: TerminalCell[];
|
||||
isWrapped: boolean;
|
||||
}
|
||||
|
||||
// ── 光标状态 ──────────────────────────────────────────────────────────────────
|
||||
export interface CursorState {
|
||||
x: number; // 列(0-based)
|
||||
y: number; // 行(视口相对,0-based)
|
||||
globalRow: number; // baseY + y(全局行号,供触摸激活带使用)
|
||||
visible: boolean; // DECTCEM
|
||||
}
|
||||
|
||||
// ── 快照(渲染器唯一输入数据结构) ─────────────────────────────────────────────
|
||||
export interface TerminalSnapshot {
|
||||
cols: number;
|
||||
rows: number;
|
||||
cursor: CursorState;
|
||||
lines: TerminalLine[]; // 长度 = rows,仅当前可视区
|
||||
title: string;
|
||||
revision: number; // 每次 write() 后递增
|
||||
isAlternateBuffer: boolean;
|
||||
}
|
||||
|
||||
// ── 字符属性(VT 内部使用) ───────────────────────────────────────────────────
|
||||
export interface CharAttribs {
|
||||
fg: ColorValue;
|
||||
bg: ColorValue;
|
||||
flags: number;
|
||||
underlineStyle: "none" | "single" | "double" | "curly" | "dotted" | "dashed";
|
||||
underlineColor: ColorValue;
|
||||
}
|
||||
|
||||
export function makeDefaultAttribs(): CharAttribs {
|
||||
return {
|
||||
fg: { mode: "default", value: 0 },
|
||||
bg: { mode: "default", value: 0 },
|
||||
flags: 0,
|
||||
underlineStyle: "none",
|
||||
underlineColor: { mode: "default", value: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export function copyAttribs(a: CharAttribs): CharAttribs {
|
||||
return {
|
||||
fg: { ...a.fg },
|
||||
bg: { ...a.bg },
|
||||
flags: a.flags,
|
||||
underlineStyle: a.underlineStyle,
|
||||
underlineColor: { ...a.underlineColor },
|
||||
};
|
||||
}
|
||||
|
||||
export function makeBlankCell(attribs?: CharAttribs): TerminalCell {
|
||||
if (!attribs) {
|
||||
return { char: " ", width: 1, fg: DEFAULT_FG, bg: DEFAULT_BG, flags: 0, underlineStyle: "none", underlineColor: DEFAULT_COLOR };
|
||||
}
|
||||
return {
|
||||
char: " ", width: 1,
|
||||
fg: { ...attribs.fg },
|
||||
bg: { ...attribs.bg },
|
||||
flags: attribs.flags,
|
||||
underlineStyle: attribs.underlineStyle,
|
||||
underlineColor: { ...attribs.underlineColor },
|
||||
};
|
||||
}
|
||||
21
terminal/packages/terminal-core/tsconfig.json
Normal file
21
terminal/packages/terminal-core/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["esnext"],
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user