翻译:
最小终端UI框架,有差分渲染和同步输出,用于flicker无交互CLI应用程序.
特征
- 区别对待: 三战略渲染系统,只更新变化
- 同步输出: CSI 2026用于原子屏幕更新(无闪烁)
- 括号粘贴模式: 正确处理大于10行粘贴的大型粘贴
- 基于构成部分: 具有渲染( ) 方法的简单组件接口
- 主题支持: 组件接受自定义式的主题界面
- 内建组件: 文本、 截断文本、 输入、 编辑器、 Markdown、 装入器、 选择列表、 设置列表、 空间器、 图像、 框、 容器
- 内含图像: 在支持 Kitty 或 iTerm2 图形协议的终端中渲染图像
- 自动完整支持: 文件路径和斜线命令
快速启动
import { TUI, Text, Editor, ProcessTerminal, matchesKey } from "@ashx-j/lunr-tui";
// Create terminal
const terminal = new ProcessTerminal();
// Create TUI
const tui = new TUI(terminal);
// Add components
tui.addChild(new Text("Welcome to my app!"));
import { defaultEditorTheme as editorTheme } from './test/test-themes.ts';
const editor = new Editor(tui, editorTheme);
editor.onSubmit = (text) => {
console.log("Submitted:", text);
tui.addChild(new Text(`You said: ${text}`));
};
tui.addChild(editor);
// Focus the editor so it receives keyboard input
tui.setFocus(editor);
// In raw mode Ctrl+C doesn't send SIGINT — intercept it here to allow exit
tui.addInputListener((data) => {
if (matchesKey(data, 'ctrl+c')) {
tui.stop();
process.exit(0);
}
});
// Start
tui.start();
核心 API
图瓦卢
管理组件和渲染的主要容器.
const tui = new TUI(terminal);
tui.addChild(component);
tui.removeChild(component);
tui.start();
tui.stop();
tui.requestRender(); // Request a re-render
// Global debug key handler (Shift+Ctrl+D)
tui.onDebug = () => console.log("Debug triggered");
重叠
重叠将组件置于现有内容之上而不取而代之。 用于对话框、菜单和模式UI.
// Show overlay with default options (centered, max 80 cols)
const handle = tui.showOverlay(component);
// Show overlay with custom positioning and sizing
// Values can be numbers (absolute) or percentage strings (e.g., "50%")
const handle = tui.showOverlay(component, {
// Sizing
width: 60, // Fixed width in columns
width: "80%", // Width as percentage of terminal
minWidth: 40, // Minimum width floor
maxHeight: 20, // Maximum height in rows
maxHeight: "50%", // Maximum height as percentage of terminal
// Anchor-based positioning (default: 'center')
anchor: 'bottom-right', // Position relative to anchor point
offsetX: 2, // Horizontal offset from anchor
offsetY: -1, // Vertical offset from anchor
// Percentage-based positioning (alternative to anchor)
row: "25%", // Vertical position (0%=top, 100%=bottom)
col: "50%", // Horizontal position (0%=left, 100%=right)
// Absolute positioning (overrides anchor/percent)
row: 5, // Exact row position
col: 10, // Exact column position
// Margin from terminal edges
margin: 2, // All sides
margin: { top: 1, right: 2, bottom: 1, left: 2 },
// Responsive visibility
visible: (termWidth, termHeight) => termWidth >= 100 // Hide on narrow terminals
// Focus behavior
nonCapturing: true // Don't auto-focus when shown
});
// OverlayHandle methods
handle.hide(); // Permanently remove the overlay
handle.setHidden(true); // Temporarily hide (can show again)
handle.setHidden(false); // Show again after hiding
handle.isHidden(); // Check if temporarily hidden
handle.focus(); // Focus and bring to visual front
handle.unfocus(); // Release focus to normal fallback
handle.unfocus({ target: baseComponent }); // Release this overlay to a specific component
handle.unfocus({ target: null }); // Release this overlay and leave focus empty
handle.isFocused(); // Check if overlay has focus
handle.unfocus();
// Overlay loses focus; TUI falls back to another visible capturing overlay or the previous focus target.
handle.unfocus({ target: null });
// Overlay loses focus; no component receives input until focus is set again.
// A focused visible overlay reclaims keyboard input after temporary replacement UI
// releases focus. If you want a specific component to receive input while overlays remain
// visible, call handle.unfocus({ target: component }).
// Hide topmost overlay
tui.hideOverlay();
// Check if any visible overlay is active
tui.hasOverlay();
锁定值数字 : 'center', (中文(简体) ), 'top-left', (中文(简体) ), 'top-right', (中文(简体) ), 'bottom-left', (中文(简体) ), 'bottom-right', (中文(简体) ), 'top-center', (中文(简体) ), 'bottom-center', (中文(简体) ), 'left-center', (中文(简体) ), 'right-center'
决议令数字 :
minWidth宽度计算后作为下限应用- 职位:绝对
row编号 :col> 百分比row编号 :col> 翻译:anchor margin夹住最后位置以停留在终端界限内visible回调控制覆盖是否构成( 称为每个框架)
组件接口
所有组成部分执行:
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
handleClick?(localY: number, width: number, localX?: number): boolean;
invalidate?(): void;
}
| 方法 | 说明 |
|---|---|
render(width) | 返回一组字符串,每行一个。 每行 不得超过 width 否则TUI会出错. 使用 truncateToWidth() 或人工包装以确保这一点. |
handleInput?(data) | 当组件有焦点并接收键盘输入时调用. 这个 data 字符串包含原始终端输入(可能包括ANSI逃生序列). |
handleClick?(localY, width, localX?) | 请求点击组件内部零基坐标 . localX 是对与行处理器兼容的可选。 返回时 true 当处理. |
invalidate?() | 请求清除缓存的状态 。 部件应该从零开始在下一个 render() 电话. |
TUI 附加一个完整的 SGR 重置, OSC 8 重置在每个已渲染行的末尾. 样式不会跨行。 如果您发出多行文本带有样式, 每行重应用样式或使用 wrapTextWithAnsi() 因此,样式被保留在每条被包裹的行上.
可焦点接口( IME 支持)
显示文本光标并需要 IME (输入法编辑器) 支持的组件应当执行 Focusable 接口 :
import { CURSOR_MARKER, type Component, type Focusable } from "@ashx-j/lunr-tui";
class MyInput implements Component, Focusable {
focused: boolean = false; // Set by TUI when focus changes
render(width: number): string[] {
const marker = this.focused ? CURSOR_MARKER : "";
// Emit marker right before the fake cursor
return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`];
}
}
当a Focusable 部分有重点,TUI:
- 设定
focused = true内容 - 为
CURSOR_MARKER(零宽装甲运兵车出逃序列) - 将硬件终端光标定位在该位置
- 仅在硬件光标时显示
showHardwareCursor已启用
光标默认仍然隐藏 。 这保持了假光标渲染,同时仍然将硬件光标定位到以隐藏光标跟踪IME候选窗口的终端上. 一些终端需要一个可见的IME定位硬件光标; 启用它与 TUI 构造器选项, setShowHardwareCursor(true),或 PI_HARDWARE_CURSOR=1编辑 Editor 财务报告和审定财务报表 Input 内建组件已经执行此接口.
装有输入的容器组件: 当容器组件(对话框、选择器等)包含一个 Input 或者说 Editor 儿童,容器必须执行 Focusable 并向儿童宣传重点状态:
import { Container, type Focusable, Input } from "@ashx-j/lunr-tui";
class SearchDialog extends Container implements Focusable {
private searchInput: Input;
// Propagate focus to child input for IME cursor positioning
private _focused = false;
get focused(): boolean { return this._focused; }
set focused(value: boolean) {
this._focused = value;
this.searchInput.focused = value;
}
constructor() {
super();
this.searchInput = new Input();
this.addChild(this.searchInput);
}
}
没有这种传播, 输入一个IME( 中文、 日文、 韩文等) 将会显示候选人的窗口位置错误 .
内建组件
集装箱
儿童组.
const container = new Container();
container.addChild(component);
container.removeChild(component);
方框
适用于所有儿童的贴花和背景颜色的容器.
const box = new Box(
1, // paddingX (default: 1)
1, // paddingY (default: 1)
(text) => chalk.bgGray(text) // optional background function
);
box.addChild(new Text("Content"));
box.setBgFn((text) => chalk.bgBlue(text)); // Change background dynamically
文本
显示多行文字并用字包和粘贴.
const text = new Text(
"Hello World", // text content
1, // paddingX (default: 1)
1, // paddingY (default: 1)
(text) => chalk.bgGray(text) // optional background function
);
text.setText("Updated text");
text.setCustomBgFn((text) => chalk.bgBlue(text));
截断文本
单行文本可切换以适应取景口宽度. 用于状态行和标题 .
const truncated = new TruncatedText(
"This is a very long line that will be truncated...",
0, // paddingX (default: 0)
0 // paddingY (default: 0)
);
投入
单行文字输入并横向滚动.
const input = new Input();
input.onSubmit = (value) => console.log(value);
input.setValue("initial");
input.getValue();
密钥绑定 :
Enter- 提交Ctrl+A编号 :Ctrl+E- 线路始末Ctrl+W或者说Alt+Backspace- 向后删除单词Ctrl+U- 删除为行首Ctrl+K- 删除到行尾Ctrl+Left编号 :Ctrl+Right- 文字导航Alt+Left编号 :Alt+Right- 文字导航- 箭头密钥, 后空格, 按预期删除工作
编辑器
多行文本编辑器,具有自动完成,文件完成,粘贴处理等功能,并在内容超过终端高度时进行垂直滚动.
interface EditorTheme {
borderColor: (str: string) => string;
selectList: SelectListTheme;
}
interface EditorOptions {
paddingX?: number; // Horizontal padding (default: 0)
}
const editor = new Editor(tui, theme, options?); // tui is required for height-aware scrolling
editor.onSubmit = (text) => console.log(text);
editor.onChange = (text) => console.log("Changed:", text);
editor.disableSubmit = true; // Disable submit temporarily
editor.setAutocompleteProvider(provider);
editor.borderColor = (s) => chalk.blue(s); // Change border dynamically
editor.setPaddingX(1); // Update horizontal padding dynamically
editor.getPaddingX(); // Get current padding
特征 :
- 多行编辑并装有字包
- 斜线命令自动完成( 类型)
/(中文(简体) ) - 文件路径自动完成( 按
Tab(中文(简体) ) - 大型粘贴处理( > 10行创建)
[paste #1 +50 lines]标记) - 水平行上方/下方编辑器
- 假光标渲染( 隐藏真实光标)
密钥绑定 :
Enter- 提交Shift+Enter, (中文(简体) ),Ctrl+Enter,或Alt+Enter- 新线(依赖地铁,Alt+Enter最可靠)Tab- 自动完成Ctrl+K- 删除到行尾Ctrl+U- 删除为行首Ctrl+W或者说Alt+Backspace- 向后删除单词Alt+D或者说Alt+Delete- 删除前面的字Ctrl+A编号 :Ctrl+E- 线路始末Ctrl+]- 跳到字符上(听到下个按键,然后将光标移动到第一次出现)Ctrl+Alt+]- 向后跳转到字符- 箭头密钥, 后空格, 按预期删除工作
标记
以语法加亮和主题支持来标记下.
interface MarkdownTheme {
heading: (text: string) => string;
link: (text: string) => string;
linkUrl: (text: string) => string;
code: (text: string) => string;
codeBlock: (text: string) => string;
codeBlockBorder: (text: string) => string;
quote: (text: string) => string;
quoteBorder: (text: string) => string;
hr: (text: string) => string;
listBullet: (text: string) => string;
bold: (text: string) => string;
italic: (text: string) => string;
strikethrough: (text: string) => string;
underline: (text: string) => string;
highlightCode?: (code: string, lang?: string) => string[];
}
interface DefaultTextStyle {
color?: (text: string) => string;
bgColor?: (text: string) => string;
bold?: boolean;
italic?: boolean;
strikethrough?: boolean;
underline?: boolean;
}
const md = new Markdown(
"# Hello\n\nSome **bold** text",
1, // paddingX
1, // paddingY
theme, // MarkdownTheme
defaultStyle // optional DefaultTextStyle
);
md.setText("Updated markdown");
特征 :
- 标题、粗体、斜体、代码块、列表、链接、块词
- HTML 标记作为纯文本渲染
- 可选语法高亮通过
highlightCode - 铺设支持
- 为性能进行缓冲
装入器
动画装入自旋器.
const loader = new Loader(
tui, // TUI instance for render updates
(s) => chalk.cyan(s), // spinner color function
(s) => chalk.gray(s), // message color function
"Loading..." // message (default: "Loading...")
);
loader.start();
loader.setMessage("Still loading...");
loader.stop();
可切换连接器
以 Escape 密钥处理方式扩展加载器, 并中止用于取消ASync 操作的信号 .
const loader = new CancellableLoader(
tui, // TUI instance for render updates
(s) => chalk.cyan(s), // spinner color function
(s) => chalk.gray(s), // message color function
"Working..." // message
);
loader.onAbort = () => done(null); // Called when user presses Escape
doAsyncWork(loader.signal).then(done);
属性 :
signal: AbortSignal- 用户按出逃时中止aborted: boolean- 装载器是否中止onAbort?: () => void- 用户按出逃时召回
选择列表
与键盘导航交互选择列表 .
interface SelectItem {
value: string;
label: string;
description?: string;
}
interface SelectListTheme {
selectedPrefix: (text: string) => string;
selectedText: (text: string) => string;
description: (text: string) => string;
scrollInfo: (text: string) => string;
noMatch: (text: string) => string;
}
const list = new SelectList(
[
{ value: "opt1", label: "Option 1", description: "First option" },
{ value: "opt2", label: "Option 2", description: "Second option" },
],
5, // maxVisible
theme // SelectListTheme
);
list.onSelect = (item) => console.log("Selected:", item);
list.onCancel = () => console.log("Cancelled");
list.onSelectionChange = (item) => console.log("Highlighted:", item);
list.setFilter("opt"); // Filter items
控件 :
- 箭头密钥: 导航
- 输入: 选择
- 逃脱: 取消
设置列表
设置带有值循环和子菜单的面板 .
interface SettingItem {
id: string;
label: string;
description?: string;
currentValue: string;
values?: string[]; // If provided, Enter/Space cycles through these
submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
}
interface SettingsListTheme {
label: (text: string, selected: boolean) => string;
value: (text: string, selected: boolean) => string;
description: (text: string) => string;
cursor: string;
hint: (text: string) => string;
}
const settings = new SettingsList(
[
{ id: "theme", label: "Theme", currentValue: "dark", values: ["dark", "light"] },
{ id: "model", label: "Model", currentValue: "gpt-4", submenu: (val, done) => modelSelector },
],
10, // maxVisible
theme, // SettingsListTheme
(id, newValue) => console.log(`${id} changed to ${newValue}`),
() => console.log("Cancelled")
);
settings.updateValue("theme", "light");
控件 :
- 箭头密钥: 导航
- 输入/ 空间: 激活( 周期值或打开子菜单)
- 逃脱: 取消
空间器
垂直间距的空行.
const spacer = new Spacer(2); // 2 empty lines (default: 1)
图像
将支持 Kitty 图形协议( Kitty, Ghostty, Wez Term) 或 iTerm2 内置图像的终端内置图像 。 返回到不支持的终端上的文本占位符 .
interface ImageTheme {
fallbackColor: (str: string) => string;
}
interface ImageOptions {
maxWidthCells?: number;
maxHeightCells?: number;
filename?: string;
}
const image = new Image(
base64Data, // base64-encoded image data
"image/png", // MIME type
theme, // ImageTheme
options // optional ImageOptions
);
tui.addChild(image);
所支持的格式:PNG,JPEG,GIF,WebP. 从图像头自动解析尺寸 .
自动完成
组合自动完成器
支持斜线命令和文件路径.
import { CombinedAutocompleteProvider } from "@ashx-j/lunr-tui";
const provider = new CombinedAutocompleteProvider(
[
{ name: "help", description: "Show help" },
{ name: "clear", description: "Clear screen" },
{ name: "delete", description: "Delete last message" },
],
process.cwd() // base path for file completion
);
editor.setAutocompleteProvider(provider);
特征 :
- 类型
/显示斜线命令 - 新闻
Tab文件路径补全 - 与
~/, (中文(简体) ),./, (中文(简体) ),../,以及@前缀 - 可附加文件的过滤器
@前缀
密钥检测
使用 matchesKey() 与 Key 用于检测键盘输入的助手( 支持 Kitty 键盘协议 ) :
import { matchesKey, Key } from "@ashx-j/lunr-tui";
if (matchesKey(data, Key.ctrl("c"))) {
process.exit(0);
}
if (matchesKey(data, Key.enter)) {
submit();
} else if (matchesKey(data, Key.escape)) {
cancel();
} else if (matchesKey(data, Key.up)) {
moveUp();
}
关键标识符 (使用时 Key.* 自动补全,或字符串字元:
- 基本密钥 :
Key.enter, (中文(简体) ),Key.escape, (中文(简体) ),Key.tab, (中文(简体) ),Key.space, (中文(简体) ),Key.backspace, (中文(简体) ),Key.delete, (中文(简体) ),Key.home, (中文(简体) ),Key.end - 箭头键 :
Key.up, (中文(简体) ),Key.down, (中文(简体) ),Key.left, (中文(简体) ),Key.right - 带有修改符 :
Key.ctrl("c"), (中文(简体) ),Key.shift("tab"), (中文(简体) ),Key.alt("left"), (中文(简体) ),Key.ctrlShift("p") - 字符串格式也起作用 :
"enter", (中文(简体) ),"ctrl+c", (中文(简体) ),"shift+tab", (中文(简体) ),"ctrl+shift+p"
区别对待
TUI使用三个渲染策略:
- 第一个: 输出所有行而不清理回滚
- 视图端口上方的宽度已更改或更改: 清除屏幕并全部重置
- 普通更新: 将光标移动到第一个已更改行上, 清除到结束, 渲染已更改行
所有更新都包含在 同步输出 (单位:千美元)\x1b[?2026h . \x1b[?2026l用于原子,无闪光渲染.
终端接口
TUI 与执行 Terminal 接口 :
interface Terminal {
start(onInput: (data: string) => void, onResize: () => void): void;
stop(): void;
write(data: string): void;
get columns(): number;
get rows(): number;
moveBy(lines: number): void;
hideCursor(): void;
showCursor(): void;
clearLine(): void;
clearFromCursor(): void;
clearScreen(): void;
}
内建实施:
ProcessTerminal- 用途process.stdin/stdoutVirtualTerminal- 测试(用途)@xterm/headless(中文(简体) )
公用事业
import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@ashx-j/lunr-tui";
// Get visible width of string (ignoring ANSI codes)
const width = visibleWidth("\x1b[31mHello\x1b[0m"); // 5
// Truncate string to width (preserving ANSI codes, adds ellipsis)
const truncated = truncateToWidth("Hello World", 8); // "Hello..."
// Truncate without ellipsis
const truncatedNoEllipsis = truncateToWidth("Hello World", 8, ""); // "Hello Wo"
// Wrap text to width (preserving ANSI codes across line breaks)
const lines = wrapTextWithAnsi("This is a long line that needs wrapping", 20);
// ["This is a long line", "that needs wrapping"]
创建自定义组件
当创建自定义组件时, 返回的每行 render() 不得超过 width 参数。如果有线比终端宽,则TUI会出错.
处理输入
使用 matchesKey() 与 Key 键盘输入帮助器 :
import { matchesKey, Key, truncateToWidth } from "@ashx-j/lunr-tui";
import type { Component } from "@ashx-j/lunr-tui";
class MyInteractiveComponent implements Component {
private selectedIndex = 0;
private items = ["Option 1", "Option 2", "Option 3"];
public onSelect?: (index: number) => void;
public onCancel?: () => void;
handleInput(data: string): void {
if (matchesKey(data, Key.up)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
} else if (matchesKey(data, Key.down)) {
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
} else if (matchesKey(data, Key.enter)) {
this.onSelect?.(this.selectedIndex);
} else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
this.onCancel?.();
}
}
render(width: number): string[] {
return this.items.map((item, i) => {
const prefix = i === this.selectedIndex ? "> " : " ";
return truncateToWidth(prefix + item, width);
});
}
}
处理行宽
利用所提供的水电设施确保线路符合:
import { visibleWidth, truncateToWidth } from "@ashx-j/lunr-tui";
import type { Component } from "@ashx-j/lunr-tui";
class MyComponent implements Component {
private text: string;
constructor(text: string) {
this.text = text;
}
render(width: number): string[] {
// Option 1: Truncate long lines
return [truncateToWidth(this.text, width)];
// Option 2: Check and pad to exact width
const line = this.text;
const visible = visibleWidth(line);
if (visible > width) {
return [truncateToWidth(line, width)];
}
// Pad to exact width (optional, for backgrounds)
return [line + " ".repeat(width - visible)];
}
}
ANSI 守则考虑
两者 visibleWidth() 财务报告和审定财务报表 truncateToWidth() 正确处理 ANSI 逃生代码 :
visibleWidth()计算宽度时忽略 ANSI 代码truncateToWidth()保存 ANSI 代码并在切换时正确关闭
import chalk from "chalk";
const styled = chalk.red("Hello") + " " + chalk.blue("World");
const width = visibleWidth(styled); // 11 (not counting ANSI codes)
const truncated = truncateToWidth(styled, 8); // Red "Hello" + " W..." with proper reset
缓存
就性能而言,组件应缓存其产出,并仅在必要时重新交付:
class CachedComponent implements Component {
private text: string;
private cachedWidth?: number;
private cachedLines?: string[];
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
const lines = [truncateToWidth(this.text, width)];
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}
示例
见 test/chat-simple.ts 用于完整聊天接口示例:
- 带有自定义背景颜色的下沉信件
- 回复时正在装入自旋器
- 有自动完成和斜线命令的编辑器
- 信件之间的空格
运行它:
npx tsx test/chat-simple.ts
发展
# Install dependencies (from monorepo root)
npm install
# Run type checking
npm run check
# Run the demo
npx tsx test/chat-simple.ts
调试日志
设定 PI_TUI_WRITE_LOG 以捕捉写成 stdout 的原始 ANSI 流.
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx test/chat-simple.ts