@f5-sales-demo/pi-tui (英语)
最小终端UI框架,有差分渲染和同步输出,用于flicker无交互CLI应用程序.
特征
- 区别对待: 三战略渲染系统,只更新变化
- 同步输出: CSI 2026用于原子屏幕更新(无闪烁)
- 括号粘贴模式: 正确处理大于10行粘贴的大型粘贴
- 基于构成部分: 具有渲染( ) 方法的简单组件接口
- 主题支持: 组件接受自定义式的主题界面
- 内建组件: 文本、 截断文本、 输入、 编辑器、 Markdown、 装入器、 选择列表、 设置列表、 空间器、 图像、 框、 容器
- 内含图像: 在支持 Kitty 或 iTerm2 图形协议的终端中渲染图像
- 自动完整支持: 文件路径和斜线命令
快速启动
import { TUI, Text, Editor, ProcessTerminal } from "@f5-sales-demo/pi-tui";
// Create terminal
const terminal = new ProcessTerminal();
// Create TUI
const tui = new TUI(terminal);
// Add components
tui.addChild(new Text("Welcome to my app!"));
const editor = new Editor(editorTheme);
editor.onSubmit = (text) => {
console.log("Submitted:", text);
tui.addChild(new Text(`You said: ${text}`));
};
tui.addChild(editor);
// 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");
组件接口
所有组成部分执行:
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
invalidate?(): void;
}
| 方法 | 说明 |
|---|---|
render(width) | 返回一组字符串,每行一个。 每行 不得超过 width 否则TUI会出错. 使用 truncateToWidth() 或人工包装以确保这一点. |
handleInput?(data) | 当组件有焦点并接收键盘输入时调用. 这个 data 字符串包含原始终端输入(可能包括ANSI逃生序列). |
invalidate?() | 请求清除缓存的状态 。 部件应该从零开始在下一个 render() 电话. |
内建组件
集装箱
儿童组.
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 SymbolTheme {
cursor: string;
ellipsis: string;
boxRound: {
topLeft: string;
topRight: string;
bottomLeft: string;
bottomRight: string;
horizontal: string;
vertical: string;
};
boxSharp: {
topLeft: string;
topRight: string;
bottomLeft: string;
bottomRight: string;
horizontal: string;
vertical: string;
teeDown: string;
teeUp: string;
teeLeft: string;
teeRight: string;
cross: string;
};
table: {
topLeft: string;
topRight: string;
bottomLeft: string;
bottomRight: string;
horizontal: string;
vertical: string;
teeDown: string;
teeUp: string;
teeLeft: string;
teeRight: string;
cross: string;
};
quoteBorder: string;
hrChar: string;
spinnerFrames: string[];
}
interface EditorTheme {
borderColor: (str: string) => string;
selectList: SelectListTheme;
symbols: SymbolTheme;
}
const editor = new Editor(theme);
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
特征 :
- 多行编辑并装有字包
- 斜线命令自动完成( 类型)
/(中文(简体) ) - 文件路径自动完成( 按
Tab(中文(简体) ) - 大型粘贴处理( > 10行创建)
[paste #1 +50 lines]标记) - 水平行上方/下方编辑器
- 假光标渲染( 隐藏真实光标)
密钥绑定 :
Enter- 提交Shift+Enter, (中文(简体) ),Ctrl+Enter,或Alt+Enter- 新线(依赖地铁,Alt+Enter最可靠)Tab- 自动完成Ctrl+K- 删除行Alt+D编号 :Alt+Delete- 删除前面的字Ctrl+A编号 :Ctrl+E- 线路始末Ctrl+-- 撤销上次编辑- 箭头密钥, 后空格, 按预期删除工作
标记
以语法加亮和主题支持来标记下.
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[];
symbols: SymbolTheme;
}
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
2, // optional code block indent (spaces)
);
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;
symbols: SymbolTheme;
}
const list = new SelectList(
[
{ value: "opt1", label: "Option 1", description: "First option" },
{ value: "opt2", label: "Option 2", description: "Second option" },
],
5, // maxVisible
theme, // SelectListTheme
{ presentation: "compact-with-selected-detail" },
);
list.onSelect = (item) => console.log("Selected:", item);
list.onCancel = () => console.log("Cancelled");
list.onSelectionChange = (item) => console.log("Highlighted:", item);
list.setFilter("opt"); // Filter items
每张列表都会选择一个展示合同. 使用 compact-with-selected-detail 用于密集的一行; 当
选中的标签或描述不符合 , SelectList 将其完整的包裹值添加到行下. 使用
wrapped-prose 当每个可见的项应该包起来而不是切换时.
控件 :
- 箭头密钥: 导航
- 输入: 选择
- 逃脱: 取消
设置列表
设置带有值循环和子菜单的面板 .
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 内置图像的终端内置图像 。 返回到不支持的终端上的文本占位符 。 在Herdr内部,图像需要服务器提供 HERDR_KITTY_GRAPHICS=1 能力。 赫德中已失效的图形即使外端终端支持图像,也会选择文本回落.
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 "@f5-sales-demo/pi-tui";
import { getProjectDir } from "@f5-sales-demo/pi-utils";
const provider = new CombinedAutocompleteProvider(
[
{ name: "help", description: "Show help" },
{ name: "clear", description: "Clear screen" },
{ name: "delete", description: "Delete last message" },
],
getProjectDir(), // base path for file completion
);
editor.setAutocompleteProvider(provider);
特征 :
- 类型
/显示斜线命令 - 新闻
Tab文件路径补全 - 与
~/, (中文(简体) ),./, (中文(简体) ),../,以及@前缀 - 可附加文件的过滤器
@前缀
密钥检测
用于检测键盘输入的帮助器功能( 支持 Kitty 键盘协议 ):
import {
isEnter,
isEscape,
isTab,
isShiftTab,
isArrowUp,
isArrowDown,
isArrowLeft,
isArrowRight,
isCtrlA,
isCtrlC,
isCtrlE,
isCtrlK,
isCtrlO,
isCtrlP,
isCtrlLeft,
isCtrlRight,
isAltLeft,
isAltRight,
isShiftEnter,
isAltEnter,
isShiftCtrlO,
isShiftCtrlD,
isShiftCtrlP,
isBackspace,
isDelete,
isHome,
isEnd,
// ... and more
} from "@f5-sales-demo/pi-tui";
if (isCtrlC(data)) {
process.exit(0);
}
区别对待
TUI使用三个渲染策略:
- 第一个: 输出所有行而不清理回滚
- 视图端口上方的宽度已更改或更改: 清除屏幕并全部重置
- 普通更新: 将光标移动到第一个已更改行上, 清除到结束, 渲染已更改行
所有更新都包含在 同步输出 (单位:千美元)\x1b[?2026h . \x1b[?2026l用于原子,无闪光渲染.
终端接口
TUI 与执行 Terminal 接口 :
interface Terminal {
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => 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 { Ellipsis, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@f5-sales-demo/pi-tui";
// Get visible width of string (ignoring ANSI codes, uses Bun.stringWidth)
const width = visibleWidth("\x1b[31mHello\x1b[0m"); // 5
// Truncate string to width (preserving ANSI codes, adds ellipsis)
const truncated = truncateToWidth("Hello World", 8); // "Hello…" (default: Ellipsis.Unicode)
// Truncate without ellipsis
const truncatedNoEllipsis = truncateToWidth("Hello World", 8, Ellipsis.Omit); // "Hello Wo"
// Wrap text to width (Bun.wrapAnsi word wrap, trims line ends, preserves ANSI)
const lines = wrapTextWithAnsi("This is a long line that needs wrapping", 20);
// ["This is a long line", "that needs wrapping"]
创建自定义组件
当创建自定义组件时, 返回的每行 render() 不得超过 width 参数。如果有线比终端宽,则TUI会出错.
处理输入
使用密钥检测工具处理键盘输入 :
import { isEnter, isEscape, isArrowUp, isArrowDown, isCtrlC, isTab, isBackspace } from "@f5-sales-demo/pi-tui";
import type { Component } from "@f5-sales-demo/pi-tui";
class MyInteractiveComponent implements Component {
private selectedIndex = 0;
private items = ["Option 1", "Option 2", "Option 3"];
onSelect?: (index: number) => void;
onCancel?: () => void;
handleInput(data: string): void {
if (isArrowUp(data)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
} else if (isArrowDown(data)) {
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
} else if (isEnter(data)) {
this.onSelect?.(this.selectedIndex);
} else if (isEscape(data) || isCtrlC(data)) {
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 "@f5-sales-demo/pi-tui";
import type { Component } from "@f5-sales-demo/pi-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(),以及 wrapTextWithAnsi() 正确处理 ANSI 逃生代码 :
visibleWidth()计算宽度时忽略 ANSI 代码(通过Bun.stringWidth(中文(简体) )truncateToWidth()保存 ANSI 代码并在切换时正确关闭wrapTextWithAnsi()保存 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