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
109 lines
3.5 KiB
TypeScript
109 lines
3.5 KiB
TypeScript
// 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;
|