@earendil-works/pi-tui (英语)
最小终端UI框架,有差分渲染和同步输出,用于flicker无交互CLI应用程序.
特征
- 可互换渲染器共享
TUI与主屏幕和备用屏幕执行的接口 - 区别对待: 只更新已更改行或视图行
- 应用程序所属滚动: 备用屏幕视图端口支持鼠标、 trackpad 和键盘导航
- 同步输出: CSI 2026用于原子屏幕更新(无闪烁)
- 括号粘贴模式: 正确处理大于10行粘贴的大型粘贴
- 基于构成部分: 具有渲染( ) 方法的简单组件接口
- 主题支持: 组件接受自定义式的主题界面
- 内建组件: 文本、 短写文本、 输入、 编辑器、 马克下调、 装入器、 选择列表、 设置列表、 鼠标登记、 空间器、 图像、 盒、 容器、 VStack、 HStack、 滚动维尤
- 内含图像: 在支持 Kitty 或 iTerm2 图形协议的终端中渲染图像
- 自动完整支持: 文件路径和斜线命令
快速启动
import { type TUI, Text, Editor, ProcessTerminal, TuiMainScreen, matchesKey } from "@earendil-works/pi-tui";
// Create terminal
const terminal = new ProcessTerminal();
// Create the default main-screen renderer through the shared TUI interface
const tui: TUI = new TuiMainScreen(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
TUI 接口和渲染器
TUI 是组件管理,焦点,覆盖,输入,生命周期,终端查询和渲染的共享接口. 仅在构建应用程序时选择混凝土渲染器 :
TuiMainScreen切入主终端缓冲器,并保存终端回滚.TuiAltScreen使另一个终端缓冲器中的固定高度取景口带有应用程序自有滚动。 停止后会恢复主缓冲并打印完整的最后文件.
import { type TUI, TuiAltScreen, TuiMainScreen } from "@earendil-works/pi-tui";
const tui: TUI = new TuiMainScreen(terminal);
// To use an application-owned viewport in the alternate terminal buffer instead:
// const tui: TUI = new TuiAltScreen(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");
颜色和终端样式
颜色是终端渲染前可以转换或混合的值 :
import {
colorToRgb,
foregroundAnsi,
getTerminalColorMode,
mixColors,
parseColor,
rgbColor,
styleText,
} from "@earendil-works/pi-tui";
const accent = parseColor("oklch(70% 0.12 220)");
const background = parseColor("#20242a");
const foreground = mixColors(accent, background, 0.2);
const text = styleText(
"Ready",
{ fg: foreground, bg: background, bold: true },
getTerminalColorMode(),
);
Color 是一个索引化的 ANSI 颜色, 一个 sRGB 颜色, 或者一个 OKLCH 颜色 。 每个颜色都转换为 sRGB, 所以颜色数学如 mixColors() 总是工作。 索引0-15跟随用户的终端调色板,因此他们的sRGB值相近. styleText() 根据请求的终端模式,将颜色转换为真色或256-color输出.
parseColor() 也接受 OKHSL, 如 okhsl(250 60% 55%)· ; okhslColor() 在代码和 colorToOkhsl() 读取任意颜色的 OKHSL 频道。 OKHSL 饱和度是相对于最允许的 sRGB 游戏在发光度和发光度,所以每个值都是以发光度为单位的,而等饱和度在发光度之间看起来同样多彩. OKHSL 颜色在创建时会转换为 sRGB .
转换不缓存 。 OKLCH的颜色,尤其是SRGB游戏外的颜色,比SRGB或有索引的颜色更需要转换. 对于每次渲染时所用的颜色,一次转换并再使用结果:
const { r, g, b } = colorToRgb(mixColors(accent, background, 0.2));
const foreground = rgbColor(r, g, b); // cheap to render repeatedly
const foregroundCode = foregroundAnsi(foreground, getTerminalColorMode());
另选屏幕取景口布局
TuiAltScreen 可以使终端高度布局明确. VStack 财务报告和审定财务报表 HStack 分配受限制区域,同时 ScrollView 拥有一个区域的滚动。 这些语义在 TuiMainScreen,终端拥有回滚.
import {
Container,
isViewportTUI,
ScrollView,
Text,
VStack,
} from "@earendil-works/pi-tui";
const transcript = new Container();
transcript.addChild(new Text("History"));
const editorAndFooter = new VStack([
editor,
new Text("status"),
]);
if (isViewportTUI(tui)) {
tui.setLayoutRoot(new VStack([
{
component: new ScrollView(transcript, {
follow: "end",
primary: true,
overscroll: "chain",
}),
basis: 0,
grow: 1,
minSize: 1,
},
{
component: editorAndFooter,
basis: "auto",
shrink: 1,
minSize: 1,
},
]));
}
堆栈条目支持 basis, (中文(简体) ), grow, (中文(简体) ), shrink, (中文(简体) ), minSize, (中文(简体) ), maxSize,并作出反应 visible 召回电话。 鼠标-轮回输入以指针下滚动视图和未使用的三角洲链为目标,默认为外滚动视图. 主滚动视图在不可滚动区域上接收了相交屏幕键盘导航动作和相轮输入. 它也可以在OSC 133语义即时标记之间跳出,匹配常见的终端即时导航快捷键. 新闻 Ctrl+Shift+F 打开或关闭其边框搜索面板。 面板显示已配置的上/下一条快捷键,并提供可点击的箭头控件;默认情况下, Enter编号 :Ctrl+G 财务报告和审定财务报表 Shift+Enter编号 :Ctrl+Shift+G 在匹配之间移动,以及 Escape 也关闭搜索. TuiAltScreenOptions.searchMatchStyle 财务报告和审定财务报表 searchCurrentMatchStyle 自定义匹配突出显示,同时 searchNavigationButtonStyle 样式每个箭头按钮并接收其悬浮状态. TuiAltScreenOptions.scrollToEndIndicator 使可点击标签以 a 的最后一行为中心 follow: "end" 主滚动视图在向末端滚动时;单击它恢复尾接.
为每个请求的框架重建布局几何。 保留了状态组件,其现有的已成行缓存仍然有效。 通话 render(width) 直接在这些布局组件上产生一个未绑定的文档,在alt模式恢复主屏幕时也会使用.
重叠
重叠将组件置于现有内容之上而不取而代之。 用于对话框、菜单和模式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.getBounds(); // Get last rendered terminal-relative bounds
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;
handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
capturesViewportInput?: boolean;
invalidate(): void;
}
| 方法 | 说明 |
|---|---|
render(width) | 返回一组字符串,每行一个。 每行 不得超过 width 否则TUI会出错. 使用 truncateToWidth() 或人工包装以确保这一点. |
handleInput?(data) | 当组件有焦点并接收键盘输入时调用. 这个 data 字符串包含原始终端输入(可能包括ANSI逃生序列). |
handleMouse?(event) | 被叫来 TuiAltScreen 用于针对组件的常规指针输入. |
capturesViewportInput? | 如果该组件有焦点时属实, TuiAltScreen 通过它的视图滚动和即时跳动键(tui.altScreen.* 但搜索除外),取而代之的是向它滚动的取景口,就像它对于一个有焦点的覆盖一样. 设定 SelectList, (中文(简体) ), SettingsList,以及 Editor 当自动完成打开时, PageUp/PageDown 移动选择。 只检查有重点的组件: 向列表中转发输入的包装符必须暴露列表本身的价值. 使用 getSelectPageIndex(keybindings, data, selectedIndex, itemCount, pageSize) 用于计算新选择. |
invalidate() | 要求。 清除任何已缓存的渲染状态, 所以下一个 render() 从头开始。 没有缓存的渲染状态的组件可以使用空执行. |
TUI 附加一个完整的 SGR 重置, OSC 8 重置在每个已渲染行的末尾. 样式不会跨行。 如果您发出多行文本带有样式, 每行重应用样式或使用 wrapTextWithAnsi() 因此,样式被保留在每条被包裹的行上.
鼠标输入
TuiAltScreen 规范 SGR 鼠标输入和命中测试组件和上覆. 活动包含当地组成部分 x编号 :y绝对值 screenX编号 :screenY、边框、按钮、修饰器、点击计数和轮三角洲. TuiMainScreen 不抓取鼠标输入,因为终端拥有回滚.
import type { TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
if (event.type === "click" && event.button === "left") {
this.expanded = !this.expanded;
return { handled: true };
}
if (event.type === "press" && event.button === "left") {
return { handled: true, capture: true, focus: true };
}
if (event.type === "drag") {
this.updateFromPointer(event.x, event.y);
return { handled: true, render: true };
}
return undefined;
}
正在返回 handled 抑制渲染器级倒置行为. capture 继续将随后的事件拖放到同一组件. focus 请求键盘焦点。 可选 render 旗下控件再油漆:按下,点击,拖动,并默认为渲染;移动和放出不. 设定 render: true 对于明显改变的徘徊状态,或 render: false 给一个处理的不操作。 招标请求是合并的,终端输出仍然有差异.
未处理的手势保留了相换屏幕默认值: 轮子输入滚动最近的 ScrollView 和链条未使用的三角洲,主按钮拖动选中的文本, OSC 8 链接在父点击处理器前打开, 未处理的右键点击保留所配置的粘贴行为 。 只有在没有拖放的情况下,才发出点击.
使用 MouseRegion 添加鼠标行为而不改变组件的渲染:
const collapsible = new MouseRegion(content, (event) => {
if (event.type !== "click" || event.button !== "left") return undefined;
expanded = !expanded;
return { handled: true };
});
Container 财务报告和审定财务报表 Box 使用最后制成的帧记录的几何来给筑巢儿童的路由事件,所以指向运动不会仅仅让儿童重新接受命中测试. 明确 VStack, (中文(简体) ), HStack,以及 ScrollView 布局直接使用可换屏幕布局框架.
可焦点接口( IME 支持)
显示文本光标并需要 IME (输入法编辑器) 支持的组件应当执行 Focusable 接口 :
import { CURSOR_MARKER, type Component, type Focusable, renderFakeCursor } from "@earendil-works/pi-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}${renderFakeCursor(atCursor)}${afterCursor}`];
}
invalidate(): void {}
}
当a Focusable 部分有重点,TUI:
- 设定
focused = true内容 - 为
CURSOR_MARKER(零宽装甲运兵车出逃序列) - 将硬件终端光标定位在该位置
- 仅在硬件光标时显示
showHardwareCursor已启用
renderFakeCursor() 将光标单元格用零宽标记来包裹。 TUI把它们当成了反向视频. 何时 showHardwareCursor 启用时, TUI 删除直接跟踪的假光标 CURSOR_MARKER,因此焦点组件只显示终端光标。 假光标, 无上一个 CURSOR_MARKER 保持可见.
硬件光标默认仍然隐藏 。 这保持了假光标渲染,同时仍然将硬件光标定位到以隐藏光标跟踪IME候选窗口的终端上. 一些终端需要用于IME定位的可见硬件光标;用渲染器构造器的来启用 showHardwareCursor 参数或 setShowHardwareCursor(true)编辑 Editor 财务报告和审定财务报表 Input 内建组件已经执行此接口.
装有输入的容器组件: 当容器组件(对话框、选择器等)包含一个 Input 或者说 Editor 儿童,容器必须执行 Focusable 并向儿童宣传重点状态:
import { Container, type Focusable, Input } from "@earendil-works/pi-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. 从图像头自动解析尺寸 .
替代屏幕图像相容性
TuiAltScreen 支持在包括Kitty和Ghostty在内的执行Kitty图形协议的终端中内置图像和部分取景端裁剪. iTerm2的内置图像协议不提供在滚动时删除已存在的放置或裁剪其出处的操作. 为了防止污秽的图像留下过量的再油漆内容, TuiAltScreen 在 iTerm2 中使图像组件成为文本占位符 . TuiMainScreen 继续正常地使 iTerm2 内置图像.
自动完成
组合自动完成器
支持斜线命令和文件路径.
import { CombinedAutocompleteProvider } from "@earendil-works/pi-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 "@earendil-works/pi-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"
渲染模式
TuiMainScreen 使用三个渲染策略:
- 第一个: 输出所有行而不清理回滚
- 视图端口上方的宽度已更改或更改: 清除屏幕并全部重置
- 普通更新: 将光标移动到第一个已更改行上, 清除到端端, 并渲染已更改行
TuiAltScreen 拥有终端高通视站. 没有明确的布局根,它保留了遗留下来的单文件滚动行为. 与 setLayoutRoot(), (中文(简体) ), VStack, (中文(简体) ), HStack并筑巢 ScrollView 组件可以保留固定区域,独立滚动受限制区域。 它更新了已设置的已更改的视图行,在下方跟踪流出输出,并在内容增长时保留手动选择的滚动位置. 鼠标-轮和可配置的键盘导航滚动,而无需修改终端回滚,包括在OSC 133语义快速标记之间的跳出. Scrollbars支持悬浮扩展,拇指拖放,并点击音轨跳出. 单击 OSC 8 超链接, 用配置的 URL 处理器打开它 。 拖动鼠标主按钮选择文本,除非 TuiAltScreenOptions.copyOnSelect 实值 false,将其复制到剪贴板上,并使用 OSC 52;在滚动视图的上边或下边自动滚动时拖放并扩展选择范围到屏幕外的内容。 Kitty 图像支持垂直取景端裁剪; ITerm2 内置图像倒回文本, 因为 iTerm2 协议无法删除, 或在取景端重新油漆时进行裁剪放置 .
两种渲染器都用 同步输出 (单位:千美元)\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 "@earendil-works/pi-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 "@earendil-works/pi-tui";
import type { Component } from "@earendil-works/pi-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);
});
}
invalidate(): void {}
}
处理行宽
利用所提供的水电设施确保线路符合:
import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
import type { Component } from "@earendil-works/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)];
}
invalidate(): void {}
}
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 用于完整聊天接口示例:
- 带有自定义背景颜色的下沉信件
- 回复时正在装入自旋器
- 有自动完成和斜线命令的编辑器
- 信件之间的空格
运行它:
node test/chat-simple.ts
发展
# Install dependencies (from monorepo root)
npm install
# Run type checking
npm run check
# Run the demo
node test/chat-simple.ts
调试日志
设定 PI_TUI_WRITE_LOG 以捕捉写成 stdout 的原始 ANSI 流.
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log node test/chat-simple.ts