@earendil-works / pi-tui
最小的终端UI框架,具有差分渲染和同步输出,用于无闪烁的交互式CLI应用程序。
功能
- 可互换的渲染器:共享
TUI与主屏幕和备用屏幕实现的接口 - 微分呈现:仅更新已更改的行或视窗行
- Application-owned滚动:交替屏幕视口支持鼠标、触控板和键盘导航
- 同步输出使用CSI 2026进行原子屏幕更新(无闪烁)
- 括号粘贴模式:正确处理大型浆糊,并标记为bbb10行浆糊
- 基于组件的:带有render()方法的简单组件接口
- 主题支持组件接受主题接口以实现可定制的样式
- 内置组件: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, mousereregion, Spacer, Image, Box, Container, VStack, HStack, ScrollView
- 内联图像:在支持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");
交替屏幕视窗布局
TuiAltScreen 可以呈现显式的终端高度布局。 VStack 和 HStack 分配受限区域,而 ScrollView 拥有一个区域的滚动。这些语义有意在 TuiMainScreen,其中终端拥有scrollback。
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 控件的最后一行为中心呈现一个可单击的标签 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;
invalidate?(): void;
}
| 方法 | 描述 |
|---|---|
render(width) | 返回字符串数组,每行一个。每一行 不得超过 width 否则TUI就会出错。使用 truncateToWidth() 或手动包装,以确保这一点。 |
handleInput?(data) | 当组件有焦点并接收键盘输入时调用。这个 data 字符串包含原始终端输入(可能包括ANSI转义序列)。 |
handleMouse?(event) | 由渲染器调用,用于针对组件的规范化指针输入;常规模式的单击需要活动的鼠标捕获租约。 |
invalidate?() | 调用以清除任何缓存的呈现状态。组件应该在下一次从头开始重新渲染 render() 呼叫。 |
TUI附加了一个完整的SGR重置和OSC 8重置在每个渲染线的末尾。风格不会跨越界线。如果发出带有样式的多行文本,请每行重新应用样式或使用 wrapTextWithAnsi() 因此,每个换行行都保留了样式。
鼠标输入
TuiAltScreen 标准化SGR鼠标输入和命中测试组件和覆盖。事件包含组件本地 x/y,绝对 screenX/screenY、边界、按钮、修饰符、单击计数和转轮增量。
TuiMainScreen 仅在主机拥有的租约处于活动状态时捕获: const release = tui.acquireMouseCapture("pending-question");呼叫 release() 当交互界面关闭时。租赁组合和释放是幂等的。替换渲染器开始时没有租约,因此主机必须在切换模式后重新应用其意图。如果没有租约,本地选择和回滚将保持不变。
普通模式只支持不修改的左键/点击。处理程序必须用 { handled: true } 没有激活;500毫秒内的同一单元释放产生点击。更改位置或目标布局将取消该手势。Wheel、motion、其他按钮和修改后的报告在没有调度的情况下被消耗,包括捕获结束后的飞行报告。当捕获处于活动状态时,使用终端的选择绕过:通常在iTerm2/ terminal .app中使用Shift-drag或Option-drag。没有常规模式的应用程序拥有的拖动选择或滚轮滚动。
当帧位置未知、调整大小或图像方位时,单击失败关闭。短的新鲜帧使用私有的光标位置查询;清除和填充视图帧提供了它们自己的锚。查询超时使键盘交互可用;清除渲染可以恢复锚。外部stdout/stderr使放置无效,下一个常规模式呈现在重新校准之前附加一个新帧,而不清除诊断或scrollback。 在非tty输出、Termux和没有Windows终端的Windows控制台上禁用跟踪。
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 并链接未使用的delta,主按钮拖动选择文本,OSC 8链接在父单击处理程序之前打开,未处理的右键单击保留配置的粘贴行为。只有当按/释放完成而没有拖动时,才会触发单击。
使用 MouseRegion 在不改变组件渲染的情况下添加鼠标行为:
const collapsible = new MouseRegion(content, (event) => {
if (event.button !== "left") return undefined;
if (event.type === "press") return { handled: true };
if (event.type !== "click") return undefined;
expanded = !expanded;
return { handled: true };
});
Container 和 Box 使用最后渲染帧记录的几何图形将事件路由到嵌套的子节点,这样指针运动就不会仅仅为了测试而渲染子节点。显式的 VStack, HStack, ScrollView 布局直接使用交替屏幕布局框架。
可调焦接口(IME支持)
显示文本光标并需要IME(输入法编辑器)支持的组件应该实现 Focusable 接口:
import { CURSOR_MARKER, type Component, type Focusable } 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}\x1b[7m${atCursor}\x1b[27m${afterCursor}`];
}
}
当一个 Focusable 组件有焦点,TUI:
- 集
focused = true在组件上 - 扫描呈现的输出
CURSOR_MARKER(零宽度APC转义序列) - 将硬件终端光标定位在该位置
- 显示硬件游标
showHardwareCursor启用了
默认情况下,光标保持隐藏状态。这将保持假游标呈现,同时仍然为跟踪具有隐藏游标的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));
TruncatedText
截断以适合视窗宽度的单行文本。用于状态行和标头。
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) - 大型粘贴处理(bbb10行创建)
[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+]-向后跳转到角色- 箭头键,退格,删除工作如预期
减价
呈现带有语法高亮显示和主题支持的markdown。
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();
CancellableLoader
扩展Loader,使用Escape键处理和一个用于取消异步操作的AbortSignal。
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-当用户按Escape键时中止aborted: boolean-加载程序是否被中止onAbort?: () => void—按“退出”键时回拨
SelectList
带有键盘导航的交互式选择列表。
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
控制:
- 鼠标移动/滚轮:在交替屏幕模式下高亮显示行
- 单击:选择一行
- 方向键:导航
- 输入:选择
- 逃脱:取消
SettingsList
设置面板与值循环和子菜单。
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, WezTerm)或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图形协议的终端中支持内联图像和部分视口裁剪,包括Kitty和Ghostty。iTerm2的内联图像协议不提供在滚动时删除现有位置或裁剪其源的操作。为了防止陈旧的图像在重新绘制的内容上残留, TuiAltScreen 将图像组件呈现为iTerm2中的文本占位符。 TuiMainScreen 继续正常渲染iTerm2内联图像。
自动完成
CombinedAutocompleteProvider
同时支持斜杠命令和文件路径。
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 使用三种渲染策略:
- 第一次呈现:输出所有行,不清除scrollback
- 宽度改变或上方视口改变:清除屏幕并完全重新渲染
- 正常的更新:将光标移动到第一行,清除到最后,并显示更改的行
TuiAltScreen 拥有终端高度的视口。没有显式的布局根,它保留了传统的单文档滚动行为。与 setLayoutRoot(), VStack, HStack,并嵌套 ScrollView 组件可以保留固定区域和独立滚动受限区域。它就地更新改变的视口行,在底部跟随流输出,并在内容增长时保留手动选择的滚动位置。鼠标滚轮和可配置的键盘导航滚动,不修改终端滚动,包括OSC 133语义提示标记之间的跳转。滚动条支持悬停扩展、拇指拖动和轨道点击跳跃。 单击OSC 8超链接将使用配置的URL处理程序打开它。使用鼠标主要按钮拖动将选择文本和,除非 TuiAltScreenOptions.copyOnSelect 是 false,使用osc52将其复制到剪贴板;在滚动视图的顶部或底部边缘按住拖动,自动滚动并将所选内容扩展到屏幕外的内容。小猫图像支持垂直视窗裁剪;iTerm2内联图像退回到文本,因为iTerm2协议不能在viewport重绘期间删除或裁剪位置。
两个渲染器都将更新封装在 同步输出 (\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/stdout
公用事业公司
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"]
颜色和终端样式
颜色是在终端渲染之前可以转换或混合的值:
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色输出。
parseColor() 也接受OKHSL,如 okhsl(250 60% 55%); okhslColor() 在代码中构建它 colorToOkhsl() 读取任何颜色的OKHSL通道。OKHSL饱和度相对于sRGB色域在该色调和亮度下允许的最大值,因此每个值都在色域内。OKHSL颜色在创建时被转换为sRGB。
转换不会被缓存,并且sRGB色域之外的OKLCH颜色转换成本最高。对于在每次渲染中使用的颜色,转换一次并重用结果:
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());
创建自定义组件
在创建自定义组件时, 返回的每一行 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);
});
}
}
处理线宽度
使用提供的工具确保线路合适:
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)];
}
}
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 一个完整的聊天界面示例:
- 具有自定义背景颜色的标记消息
- 响应期间加载旋转器
- 带有自动完成和斜杠命令的编辑器
- 消息之间的间隔符
运行该程序:
bunx tsx test/chat-simple.ts
开发
# Install dependencies (from monorepo root)
npm install
# Bun alternative: bun install
# Run type checking
bun run check
# Run the demo
bunx tsx test/chat-simple.ts
调试日志
集 PI_TUI_WRITE_LOG 捕获写入标准输出的原始ANSI流。
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log bunx tsx test/chat-simple.ts