feat(libs): add xterm-terminal IIFE library (xterm.js 6.0)

New pnpm workspace package @yggdrasil/xterm-terminal, output to
public/xterm/. Output-only terminal (disableStdin) for streaming
container stdout/stderr via SSE.

- @xterm/xterm@^6.0.0 + @xterm/addon-fit@^0.11.0 (released same day,
  2025-12-22; 0.10.0's peerDependency is ^5.0.0 and incompatible)
- convertEol: containers emit \n, xterm needs \r\n
- stderr wrapped in ANSI red for visual distinction
- Catppuccin Latte/Mocha themes matching project highlight.css
- Mirrors codemirror-editor IIFE pattern: object literal default export,
  XtermOptions as class (survives TS erasure for wasm-bindgen)
- css.d.ts declares *.css module for tsc side-effect import
This commit is contained in:
xfy 2026-07-10 11:37:04 +08:00
parent 26f7465943
commit 246d8a6d67
12 changed files with 308 additions and 0 deletions

19
libs/pnpm-lock.yaml generated
View File

@ -107,6 +107,15 @@ importers:
specifier: ^3.3.0
version: 3.3.0
xterm-terminal:
dependencies:
'@xterm/addon-fit':
specifier: ^0.11.0
version: 0.11.0
'@xterm/xterm':
specifier: ^6.0.0
version: 6.0.0
yggdrasil-core: {}
packages:
@ -589,6 +598,12 @@ packages:
'@vitest/utils@4.1.10':
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
'@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
'@xterm/xterm@6.0.0':
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@ -1462,6 +1477,10 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@xterm/addon-fit@0.11.0': {}
'@xterm/xterm@6.0.0': {}
assertion-error@2.0.1: {}
buffer-image-size@0.6.4:

View File

@ -0,0 +1,17 @@
{
"name": "@yggdrasil/xterm-terminal",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc --noEmit && vite build",
"dev": "vite",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0"
}
}

4
libs/xterm-terminal/src/css.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
// 声明 CSS side-effect import让 tsc --noEmit 不报 TS2882。
// xterm.js 的样式通过 `import '@xterm/xterm/css/xterm.css'` 引入,
// vite cssCodeSplit:false 会把它内联进 IIFE 产物。
declare module '*.css';

View File

@ -0,0 +1,28 @@
import { TerminalInstance, XtermOptions } from './terminal';
/**
* { create }
* IIFE window.XtermTerminal Rust Reflect::get
* wasm-bindgen extern fn
*/
const XtermTerminal = {
_instances: new Map<string, TerminalInstance>(),
create(containerId: string, options: XtermOptions = new XtermOptions()): TerminalInstance | null {
const container = document.getElementById(containerId);
if (!container) return null;
// 销毁同 id 的旧实例
const existing = this._instances.get(containerId);
if (existing) {
existing.destroy();
this._instances.delete(containerId);
}
const instance = new TerminalInstance(container, options);
this._instances.set(containerId, instance);
return instance;
},
};
export default XtermTerminal;

View File

@ -0,0 +1,108 @@
// xterm.js 终端实例:输出专用(无 stdin配合 SSE 流式渲染容器 stdout/stderr。
//
// 设计约束:
// - disableStdin: true —— 读者侧不可交互输入,纯输出展示。
// - convertEol: true —— 容器输出的 \n 自动转 \r\n终端换行语义
// - stderr 用 ANSI 红色前缀包裹,与 stdout 视觉区分。
//
// 镜像 codemirror-editor/src/editor.ts 的范式Options 用 class非 interface
// 以便 TS 擦除后存活wasm 侧能用 new XtermOptions() 构造。
import { FitAddon } from '@xterm/addon-fit';
import { Terminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css';
import { DARK_THEME, LIGHT_THEME, type ThemeName } from './themes';
/** XtermTerminal.create
* class interface便 TS
* wasm `new XtermOptions()` setter
*/
export class XtermOptions {
theme?: ThemeName;
fontFamily?: string;
fontSize?: number;
onReady?: () => void;
}
/** ANSI 红色 + reset 包裹 stderr 文本。 */
function red(text: string): string {
return `\x1b[31m${text}\x1b[0m`;
}
/** xterm.js 终端实例封装。输出专用,提供 stdout/stderr 流式写入与整段写入两种模式。 */
export class TerminalInstance {
private term: Terminal;
private fitAddon: FitAddon;
constructor(container: HTMLElement, options: XtermOptions) {
this.term = new Terminal({
// 容器输出是 \n终端需要 \r\n 才能回车换行convertEol 自动转换。
convertEol: true,
// 输出专用:不接收键盘输入,禁用光标闪烁。
disableStdin: true,
cursorBlink: false,
cursorStyle: 'bar',
fontFamily: options.fontFamily ?? 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
fontSize: options.fontSize ?? 13,
lineHeight: 1.3,
scrollback: 5000,
theme: options.theme === 'dark' ? DARK_THEME : LIGHT_THEME,
});
this.fitAddon = new FitAddon();
this.term.loadAddon(this.fitAddon);
this.term.open(container);
// 初始 fit让列宽适配容器宽度。
this.fitAddon.fit();
options.onReady?.();
}
/** 流式写入 stdout 块SSE stdout 事件)。 */
writeStdout(data: string): void {
this.term.write(data);
}
/** 流式写入 stderr 块SSE stderr 事件),红色显示。 */
writeStderr(data: string): void {
this.term.write(red(data));
}
/** 整段写入(轮询兜底路径拿到完整结果时):清屏后重写 stdout + stderr。 */
writeAll(stdout: string, stderr: string): void {
this.term.reset();
this.term.write(stdout);
if (stderr) {
this.term.write(red(stderr));
}
}
/** 切换主题(热切换,无需重建实例)。 */
setTheme(theme: ThemeName): void {
this.term.options.theme = theme === 'dark' ? DARK_THEME : LIGHT_THEME;
}
/** 重新计算列宽以适配容器(如父容器尺寸变化时调用)。 */
fit(): void {
this.fitAddon.fit();
}
/** 清屏(新一轮运行前调用)。 */
clear(): void {
this.term.reset();
}
/** 销毁实例,释放 DOM 与事件监听。EditorHandle::drop → destroy。 */
destroy(): void {
this.term.dispose();
}
}
// 暴露 XtermOptions 到 window供 wasm-bindgen 用 new XtermOptions()。
// IIFE 的 name 只能挂一个全局XtermTerminal故手动 hoist XtermOptions。
declare global {
interface Window {
XtermOptions: typeof XtermOptions;
}
}
window.XtermOptions = XtermOptions;

View File

@ -0,0 +1,54 @@
// 主题名:与 codemirror-editor / 项目 themes/ 下 Catppuccin Latte/Mocha 对齐。
export type ThemeName = 'light' | 'dark';
// Catppuccin Latte浅色终端配色与 highlight.css 浅色主题视觉一致。
// 色值取自 https://catppuccin.com/palette/ Latte 调色板。
export const LIGHT_THEME = {
background: '#eff1f5',
foreground: '#4c4f69',
cursor: '#dc8a78',
cursorAccent: '#eff1f5',
selectionBackground: '#acb0be',
black: '#5c5f77',
red: '#d20f39',
green: '#40a02b',
yellow: '#df8e1d',
blue: '#1e66f5',
magenta: '#ea76cb',
cyan: '#179299',
white: '#acb0be',
brightBlack: '#6c6f85',
brightRed: '#d20f39',
brightGreen: '#40a02b',
brightYellow: '#df8e1d',
brightBlue: '#1e66f5',
brightMagenta: '#ea76cb',
brightCyan: '#179299',
brightWhite: '#bcc0cc',
};
// Catppuccin Mocha深色终端配色与 highlight.css 深色主题视觉一致。
// 色值取自 https://catppuccin.com/palette/ Mocha 调色板。
export const DARK_THEME = {
background: '#1e1e2e',
foreground: '#cdd6f4',
cursor: '#f5e0dc',
cursorAccent: '#1e1e2e',
selectionBackground: '#585b70',
black: '#45475a',
red: '#f38ba8',
green: '#a6e3a1',
yellow: '#f9e2af',
blue: '#89b4fa',
magenta: '#f5c2e7',
cyan: '#94e2d5',
white: '#bac2de',
brightBlack: '#585b70',
brightRed: '#f38ba8',
brightGreen: '#a6e3a1',
brightYellow: '#f9e2af',
brightBlue: '#89b4fa',
brightMagenta: '#f5c2e7',
brightCyan: '#94e2d5',
brightWhite: '#a6adc8',
};

View File

@ -0,0 +1,4 @@
{
"extends": "../tsconfig.base.json",
"include": ["src"]
}

View File

@ -0,0 +1,27 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
export default defineConfig({
build: {
// 输出直写 public/xterm/Dioxus 直接托管,无需拷贝步骤。
outDir: resolve(__dirname, '../../public/xterm'),
emptyOutDir: true,
lib: {
entry: resolve(__dirname, 'src/index.ts'),
// IIFE 产物挂在 window.XtermTerminal 上Rust 侧用 Reflect::get 取。
name: 'XtermTerminal',
fileName: () => 'terminal.js',
formats: ['iife'],
},
rolldownOptions: {
output: {
// 默认导出(对象字面量 { create() })成为 window.XtermTerminal。
exports: 'default',
assetFileNames: 'terminal.[ext]',
},
},
cssCodeSplit: false,
minify: true,
sourcemap: true,
},
});

View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom',
include: ['src/**/*.test.ts'],
},
});

View File

@ -0,0 +1,2 @@
.xterm{cursor:text;-webkit-user-select:none;user-select:none;position:relative}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{z-index:5;position:absolute;top:0}.xterm .xterm-helper-textarea{opacity:0;z-index:-5;white-space:nowrap;resize:none;border:0;width:0;height:0;margin:0;padding:0;position:absolute;top:0;left:-9999em;overflow:hidden}.xterm .composition-view{color:#fff;white-space:nowrap;z-index:1;background:#000;display:none;position:absolute}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{cursor:default;background-color:#000;position:absolute;inset:0;overflow-y:scroll}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;top:0;left:0}.xterm-char-measure-element{visibility:hidden;line-height:normal;display:inline-block;position:absolute;top:0;left:-9999em}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{z-index:10;color:#0000;pointer-events:none;position:absolute;inset:0}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:#0000}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre;font-family:monospace}.xterm .xterm-accessibility-tree>div{transform-origin:0;width:fit-content}.xterm .live-region{width:1px;height:1px;position:absolute;left:-9999px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{-webkit-text-decoration:underline double;text-decoration:underline double}.xterm-underline-3{-webkit-text-decoration:underline wavy;text-decoration:underline wavy}.xterm-underline-4{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.xterm-underline-5{-webkit-text-decoration:underline dashed;text-decoration:underline dashed}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:underline overline}.xterm-overline.xterm-underline-2{-webkit-text-decoration:overline double underline;text-decoration:overline double underline}.xterm-overline.xterm-underline-3{-webkit-text-decoration:overline wavy underline;text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{-webkit-text-decoration:overline dotted underline;text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{-webkit-text-decoration:overline dashed underline;text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;pointer-events:none;position:absolute;top:0;right:0}.xterm-decoration-top{z-index:2;position:relative}.xterm .xterm-scrollable-element>.scrollbar{cursor:default}.xterm .xterm-scrollable-element>.scrollbar>.scra{cursor:pointer;font-size:11px!important}.xterm .xterm-scrollable-element>.visible{opacity:1;z-index:11;background:0 0;transition:opacity .1s linear}.xterm .xterm-scrollable-element>.invisible{opacity:0;pointer-events:none}.xterm .xterm-scrollable-element>.invisible.fade{transition:opacity .8s linear}.xterm .xterm-scrollable-element>.shadow{display:none;position:absolute}.xterm .xterm-scrollable-element>.shadow.top{width:100%;height:3px;box-shadow:var(--vscode-scrollbar-shadow,#000) 0 6px 6px -6px inset;display:block;top:0;left:3px}.xterm .xterm-scrollable-element>.shadow.left{width:3px;height:100%;box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset;display:block;top:3px;left:0}.xterm .xterm-scrollable-element>.shadow.top-left-corner{width:3px;height:3px;display:block;top:0;left:0}.xterm .xterm-scrollable-element>.shadow.top.left{box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset}
/*$vite$:1*/

36
public/xterm/terminal.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long