关于 pi-tui-design
面向 Pi 和 pi-tui 的终端界面设计技能,介绍布局、主题配色、键盘交互与组件实现,涵盖全屏界面、覆盖层及状态展示等呈现方式。
主要功能
- 01终端布局与字符设计
- 02主题令牌配色指引
- 03键盘交互约定
- 04组件渲染与缓存
- 05Pi 扩展的界面呈现方式
适用场景
- 01设计 Pi 终端仪表板
- 02构建键盘驱动的对话框
- 03渲染状态与进度组件
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ joelhooks/ pi-tools --skill pi-tui-design安装后在 Claude Code 中输入/pi-tui-design确认已加载。
附带文件references/examples.md
详细介绍
Pi 的 TUI 设计
构建精心打磨、而非机器生成的终端界面。终端有诸多限制——固定宽度的字符网格、只能使用键盘、颜色取决于主题——而这些限制正是设计的一部分。
设计思路
编写代码前,先确定设计方向:
- 基调:极简而精准?信息密集?活泼?工业风?终端有自己的一套美学语言——优雅的制框字符、密集的盲文图案、厚重的块元素、清晰的符号。
- 范围:全屏接管(
ctx.ui.custom)?浮动叠加层?常驻小组件?状态栏?工具渲染?让呈现方式与交互的重要程度相匹配。 - 特色:什么细节能让界面显得经过精心设计?采用盲文精度的进度条。带强调色标题的对齐列。留有呼吸空间的对话框。
终端美学语言
Unicode 字符集——你的排版工具
终端 UI 没有字体可选。取而代之的是,字符集就是排版工具:
| 类别 | 字符 | 用途 |
|---|---|---|
| 制框字符(细线) | ─│┌┐└┘├┤┬┴┼ | 标准边框、表格 |
| 制框字符(圆角) | ╭╮╰╯ | 柔和、现代的观感 |
| 制框字符(粗线) | ━┃┏┓┗┛┣┫┳┻╋ | 强调、标题 |
| 制框字符(双线) | ═║╔╗╚╝╠╣╦╩╬ | 正式、规整 |
| 块元素 | █▓▒░▀▄▌▐ | 进度条、密度、填充 |
| 盲文 | ⠀⠁⠂⠃...⣿ | 高分辨率图案、迷你图、图表 |
| 符号 | ◆●○◉◎✓✗▸▹▶▷△▽★☆♦ | 状态指示器、项目符号、选择项 |
| 数学符号/箭头 | →←↑↓↔↕⇒⟶⟵∙⋯ | 导航提示、流程 |
| Powerline | ░ | 分段分隔符(取决于终端) |
通过字符粗细建立层次:█(粗)→ ▓(中)→ ▒(细)→ ░(淡)→ (空)。用它表现视觉密度渐变,而不只是填充。
宽高比:终端字符单元格约为 2:1(高度是宽度的两倍)。██(两个块字符)看起来大致是正方形。设计空间布局时请考虑这一点——snake.ts 示例正因如此使用了 cellWidth = 2。
色彩规范
始终使用 pi 的主题令牌。 硬编码的 ANSI 转义序列会在用户切换主题时失效。
// ✗ Hardcoded — breaks on theme change
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
// ✓ Theme-aware — adapts to dark/light, custom themes
const header = theme.fg("accent", theme.bold("Title"));
const status = theme.fg("success", "✓ OK");
const muted = theme.fg("muted", "secondary info");
色彩层次(来自包含 51 个令牌的主题系统):
accent— 主要强调、选择项、活动元素text— 默认正文内容(通常为""= 终端默认色)muted— 次要、弱化内容dim— 第三级内容,几乎不可见success/error/warning— 语义状态border/borderAccent/borderMuted— 结构元素toolTitle— 类似工具的上下文中的标题
区域的背景色:selectedBg、userMessageBg、customMessageBg、toolPendingBg、toolSuccessBg、toolErrorBg。
规则:一种强调色占主导。其他所有内容都使用 muted / dim。过度使用颜色会抹平层次——满屏绿色还不如完全不用颜色。
空间构图
width 参数就是画布边界。render() 返回的每一行都不得超出它。
留白节奏:一致的水平内边距能带来视觉上的舒展感。paddingX=1(左右各一个空格)是基准值。标题或许适合用 paddingX=2。拥挤的 UI 会让人感到压迫。
对齐:数字右对齐,标签左对齐。使用 visibleWidth() 计算可识别 ANSI 序列的列宽。用空格补齐,不要用制表符。
留白空间:在各个部分之间放置一个空的 Spacer(1),比加一条分隔线更有效。让内容留有呼吸空间。
嵌套盒体:Container > Box > [children] 可实现内边距、背景和垂直堆叠。不要把所有内容都塞进一个 render 函数——应组合多个组件。
动效与动画
不使用 CSS 过渡。通过 setInterval + tui.requestRender() 定时更新。
// Spinner pattern (see Loader component)
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
this.interval = setInterval(() => {
this.frame = (this.frame + 1) % frames.length;
this.invalidate();
tui.requestRender();
}, 80);
常用模式:盲文点阵加载动画、块元素进度条、列表项依次显示(逐项渲染并添加延迟)、打字机效果文本、脉冲指示器。
必须清理:在 dispose() 中清除定时器。定时器泄漏会导致组件移除后仍继续渲染。
键盘交互设计
键盘是唯一的输入方式。让操作方式易于发现且保持一致。
标准约定(用户对此有预期):
↑↓或j/k— 浏览列表Enter— 确认/选择Escape— 取消/返回Tab— 下一个字段/或开始输入 — 搜索/筛选
始终显示提示:在页脚显示可用按键。使用 keyHint() 进行适配主题的格式化。
const help = theme.fg("dim", "↑↓ navigate • enter select • esc cancel");
使用 pi-tui 中的 matchesKey() — 它会处理终端转义序列的差异:
import { matchesKey, Key } from "@mariozechner/pi-tui";
handleInput(data: string) {
if (matchesKey(data, Key.up)) { /* ... */ }
else if (matchesKey(data, Key.enter)) { /* ... */ }
else if (matchesKey(data, Key.escape)) { /* ... */ }
else if (matchesKey(data, Key.ctrl("c"))) { /* ... */ }
}
组件约定
每个 pi-tui 组件都实现:
interface Component {
render(width: number): string[]; // Lines of output, each ≤ width
handleInput?(data: string): void; // Keyboard input when focused
wantsKeyRelease?: boolean; // Kitty protocol key release events
invalidate(): void; // Clear cached render state
}
关键规则:
render()输出的每一行都不得超过width— 使用truncateToWidth()- 状态改变时调用
invalidate(),然后调用tui.requestRender()触发重新渲染 - 缓存渲染结果(
cachedLines/cachedWidth)— 仅在状态或宽度改变时重新计算 - 逐行重新应用样式 — ANSI 样式会在行边界处重置
缓存模式
private cachedWidth?: number;
private cachedLines?: string[];
private version = 0;
private cachedVersion = -1;
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width && this.cachedVersion === this.version) {
return this.cachedLines;
}
// ... compute lines ...
this.cachedWidth = width;
this.cachedLines = lines;
this.cachedVersion = this.version;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
状态改变时递增 this.version。主题改变时会自动调用 invalidate()。
主题失效处理
如果将主题颜色预先应用到子组件中,请在 invalidate() 时重建它们:
override invalidate(): void {
super.invalidate(); // Clear child render caches
this.rebuildContent(); // Re-apply current theme colors
}
可用组件
来自 @mariozechner/pi-tui
| 组件 | 用途 | 主要 API |
|---|---|---|
Text | 自动换行的多行文本 | new Text(content, paddingX, paddingY, bgFn?)、.setText() |
TruncatedText | 截断的单行文本 | new TruncatedText(text, paddingX, paddingY) |
Box | 带内边距和背景的容器 | new Box(paddingX, paddingY, bgFn)、.addChild()、.setBgFn() |
Container | 垂直排列的子组件 | .addChild()、.removeChild()、.clear() |
Spacer | 空白垂直间距 | new Spacer(lines) |
Markdown | 带语法高亮的 Markdown 渲染 | new Markdown(content, paddingX, paddingY, mdTheme) |
Image | 终端图像(Kitty/iTerm2/Ghostty/WezTerm) | new Image(base64, mimeType, theme, options) |
SelectList | 支持筛选和滚动的交互式列表 | items、maxVisible、theme;.onSelect、.onCancel |
SettingsList | 带值的切换设置 | items、maxVisible、theme、onChange、onClose |
Loader | 带消息的盲文点阵加载动画 | new Loader(tui, spinnerColor, messageColor, message) |
CancellableLoader | 支持按 Escape 取消的加载器 | 为 Loader 包装中止信号 |
Input | 单行文本输入 | 实现 Focusable,用于 IME 光标定位 |
Editor | 多行文本编辑器 | 完整编辑功能,支持撤销、kill-ring 和按键绑定 |
来自 @mariozechner/pi-coding-agent
| 组件 | 用途 | 主要 API |
|---|---|---|
DynamicBorder | 自适应宽度的水平边框 | new DynamicBorder((s: string) => theme.fg("accent", s)) |
BorderedLoader | 带边框且支持取消的加载器 | new BorderedLoader(tui, theme, message)、.signal、.onAbort |
CustomEditor | 内置应用按键绑定的编辑器 | 扩展它以实现模态编辑(vim)和自定义快捷键 |
工具函数
import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
import { matchesKey, Key } from "@mariozechner/pi-tui";
import { DynamicBorder, getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
visibleWidth(str)— 忽略 ANSI 转义码,获取显示宽度truncateToWidth(str, width, ellipsis?)— 截断文本,可选择添加省略号wrapTextWithAnsi(str, width)— 自动换行并保留 ANSI 码matchesKey(data, key)— 将键盘输入与按键标识符进行比较getMarkdownTheme()—Markdown组件的主题对象keyHint(action, description)— 适配主题的按键绑定提示文本
交付界面
根据交互选择合适的界面:
全屏接管 — ctx.ui.custom(component)
适用于复杂交互:仪表盘、游戏、多步骤向导。
await ctx.ui.custom((tui, theme, keybindings, done) => {
return new MyComponent(tui, theme, () => done(result));
});
覆层 — ctx.ui.custom(factory, { overlay: true })
浮于现有内容之上。适用于快速选择、确认和面板。
const result = await ctx.ui.custom<string | null>(
(tui, theme, kb, done) => new MyDialog(theme, done),
{
overlay: true,
overlayOptions: {
anchor: "center", // 9 positions: center, top-left, top-center, etc.
width: "50%", // number or percentage string
minWidth: 40,
maxHeight: "80%",
offsetX: -2, offsetY: 0,
margin: 2, // or { top, right, bottom, left }
visible: (w, h) => w >= 80, // responsive hide
},
}
);
小组件 — ctx.ui.setWidget(id, lines|factory, options?)
持久显示在编辑器上方或下方。适用于状态、进度和列表。
ctx.ui.setWidget("my-widget", (tui, theme) => ({
render: () => [theme.fg("accent", "● Active") + " — 3 items pending"],
invalidate: () => {},
}));
// placement: "belowEditor" for below
// Clear: ctx.ui.setWidget("my-widget", undefined)
状态栏 — ctx.ui.setStatus(id, content)
在页脚中显示单行持久指示器。
ctx.ui.setStatus("mode", theme.fg("accent", "● DESIGN"));
// Clear: ctx.ui.setStatus("mode", undefined)
工具渲染 — renderCall/renderResult
自定义对话中的工具调用显示。返回带有 (0, 0) 内边距的 Text 组件——外层的 Box 会处理内边距。
renderCall(args, theme) {
return new Text(theme.fg("toolTitle", theme.bold("my_tool ")) + theme.fg("muted", args.action), 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Working..."), 0, 0);
let text = theme.fg("success", "✓ Done");
if (expanded) text += "\n" + theme.fg("dim", JSON.stringify(result.details, null, 2));
return new Text(text, 0, 0);
}
页脚 — ctx.ui.setFooter(factory)
替换整个页脚栏。可访问 Git 分支、扩展状态和 Token 统计信息。
ctx.ui.setFooter((tui, theme, footerData) => ({
render: (width) => [truncateToWidth(`${model} (${footerData.getGitBranch() || "no git"})`, width)],
invalidate: () => {},
dispose: footerData.onBranchChange(() => tui.requestRender()),
}));
TUI 反模式
终端 UI 中相当于“AI 垃圾内容”的做法:
| 不要这样做 | 应该这样做 |
|---|---|
硬编码 ANSI 颜色(\x1b[31m) | 使用主题令牌(theme.fg("error", ...)) |
行宽超过 width 参数 | 对每一行调用 truncateToWidth() |
不调用 invalidate()(导致缓存的渲染内容过时) | 状态变化时清除全部缓存 |
| 忽略字符单元格宽高比(2:1) | 用双宽字符绘制“方形”元素 |
| 大段无结构文本 | 使用带有 Spacer、边框和对齐的分区 |
| 缺少键盘操作提示 | 在页脚行显示可用按键 |
| 定时器或计时器泄漏 | 使用 dispose() 执行清理 |
| 主题颜色预先固定,且不会重建 | 覆盖 invalidate() 以重建主题化内容 |
| 扁平的渲染函数,没有组合结构 | 采用 Container → Box → 子项的层级结构 |
| 所有组件采用相同的视觉风格 | 为每个组件有意设计美学方向 |
| 所有加载状态都用通用旋转图标 | 使用符合上下文的进度显示(进度条、百分比、步骤计数) |
| 固定宽度布局 | 根据 width 参数自适应,并设置 minWidth 下限 |
扩展架构模式
使用 Component + Focusable 的覆层组件
Nico 的覆层始终显式建模生命周期:
import type { Component, Focusable, TUI } from "@mariozechner/pi-tui";
import type { Theme } from "@mariozechner/pi-coding-agent";
import { matchesKey } from "@mariozechner/pi-tui";
type Done = (result: string | null) => void;
class OverlayTemplate implements Component, Focusable {
focused = false;
constructor(
private tui: TUI,
private theme: Theme,
private done: Done,
) {}
handleInput(data: string): void {
if (matchesKey(data, "escape")) this.done(null);
if (matchesKey(data, "return")) this.done("selected");
}
render(_width: number): string[] {
return [];
}
invalidate(): void {}
dispose(): void {}
}
await ctx.ui.custom((tui, theme, _kb, done) => {
return new OverlayTemplate(tui, theme, (result) => done(result));
}, { overlay: true, overlayOptions: { anchor: "center", width: 64 } });
InteractiveShellOverlay 和 MessengerConfigOverlay 尤其清楚地体现了这种结构:
- 构造函数为
constructor(tui, theme, done)。 handleInput处理命令键和移动键。render(width)构建完整帧文本。invalidate清除渲染缓存。dispose移除定时器或计时器,并取消注册回调。
共享渲染辅助函数
使用可复用的辅助函数进行渲染,可以减少不同覆层之间的视觉差异:
import type { Theme } from "@mariozechner/pi-coding-agent";
import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
export function pad(s: string, len: number): string {
const vis = visibleWidth(s);
return s + " ".repeat(Math.max(0, len - vis));
}
export function row(content: string, width: number, theme: Theme): string {
const innerW = width - 2;
return theme.fg("border", "│") + pad(" " + truncateToWidth(content, innerW - 1), innerW) + theme.fg("border", "│");
}
export function renderHeader(text: string, width: number, theme: Theme): string {
const innerW = width - 2;
const padLen = Math.max(0, innerW - visibleWidth(text));
const padLeft = Math.floor(padLen / 2);
const padRight = padLen - padLeft;
return (
theme.fg("border", "╭" + "─".repeat(padLeft)) +
theme.fg("accent", text) +
theme.fg("border", "─".repeat(padRight) + "╮")
);
}
export function renderFooter(text: string, width: number, theme: Theme): string {
const innerW = width - 2;
const padLen = Math.max(0, innerW - visibleWidth(text));
const padLeft = Math.floor(padLen / 2);
const padRight = padLen - padLeft;
return (
theme.fg("border", "╰" + "─".repeat(padLeft)) +
theme.fg("dim", text) +
theme.fg("border", "─".repeat(padRight) + "╯")
);
}
pi-subagents 中的共享辅助文件(render-helpers.ts)包含 fuzzyFilter()、formatPath() 和 formatScrollInfo(),正是用于此目的。
替换页脚
ctx.ui.setFooter() 是 Nico 用于实现完整状态栏的最佳模式:
ctx.ui.setFooter((tui: any, _theme: Theme, footerData: ReadonlyFooterDataProvider) => {
footerDataRef = footerData;
tuiRef = tui;
const unsub = footerData.onBranchChange(() => tui.requestRender());
return {
dispose: unsub,
invalidate() {},
render(width: number): string[] {
const segments: string[] = [];
const ctx = buildSegmentContext(footerData, activeTheme);
const preset = getPreset("default");
for (const id of [...preset.leftSegments, ...preset.rightSegments, ...(preset.secondarySegments ?? [])]) {
const rendered = renderSegment(id, ctx);
if (rendered.visible && rendered.content) segments.push(rendered.content);
}
if (segments.length === 0) return [];
return [" " + segments.join(` ${fg(activeTheme, "separator", "⟩", colors)} `) + " "];
},
};
});
pi-powerline-footer 也通过覆盖编辑器渲染来替换编辑器和页脚行,并从 setFooter 返回 [],以保持只有一条渲染路径。
配置和设置持久化
这些代码仓库中的扩展持久化都遵循以下结构:
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
interface ExtensionSettings {
theme: "dark" | "light";
enabled: boolean;
}
const extensionDir = join(homedir(), ".pi", "agent", "extensions", "your-extension");
const settingsPath = join(extensionDir, "settings.json");
const extensionDefaults: ExtensionSettings = { theme: "dark", enabled: true };
function loadSettings(): ExtensionSettings {
if (!existsSync(settingsPath)) return extensionDefaults;
const raw = readFileSync(settingsPath, "utf-8");
return { ...extensionDefaults, ...JSON.parse(raw) };
}
function saveSettings(next: Partial<ExtensionSettings>): void {
mkdirSync(extensionDir, { recursive: true });
writeFileSync(settingsPath, JSON.stringify({ ...loadSettings(), ...next }, null, 2));
}
先与默认值合并,只有在发生变更时才保存(例如覆层中的 escape/q、设置命令等),做法与 pi-messenger 和 pi-subagent 一致。
免手动操作/后台模式
InteractiveShellOverlay 展示了后台模式的实现方式:
if (options.mode === "hands-free" || options.mode === "dispatch") {
this.state = "hands-free";
this.sessionId = options.sessionId ?? generateSessionId(options.name);
sessionManager.registerActive({
id: this.sessionId,
command: options.command,
reason: options.reason,
write: (data) => this.session.write(data),
kill: () => this.killSession(),
background: () => this.backgroundSession(),
getOutput: (opts) => this.getOutputSinceLastCheck(opts),
onComplete: (cb) => this.registerCompleteCallback(cb),
setUpdateInterval: (ms) => this.setUpdateInterval(ms),
setQuietThreshold: (ms) => this.setQuietThreshold(ms),
});
}
if (this.options.onHandsFreeUpdate) {
this.options.onHandsFreeUpdate({ status: "running", sessionId: this.sessionId, runtime: 0, tail: [], tailTruncated: false, totalCharsSent: 0, budgetExhausted: false });
}
在免手动模式下收到用户输入时,Nico 会调用接管流程(Ctrl+T、Ctrl+B 或输入的按键),停止定时更新,标记为已接管,并可选择在继续透传输入前取消注册会话所有权。
组件组合模式
对话框模式
带圆角边框和键盘操作提示的标准对话框结构:
class DialogComponent implements Component, Focusable {
focused = false;
constructor(private tui: any, private theme: Theme, private done: () => void) {}
render(width: number): string[] {
const inner = Math.max(20, width - 2);
const lines = [
`╭${"─".repeat(Math.max(0, inner - 20))} Dialog Header ${"─".repeat(Math.max(0, inner - 20))}╮`,
`│ Content line 1... │`,
`├${"─".repeat(inner)}┤`,
`│ Press \u2191\u2193 navigate • enter confirm • esc cancel │`,
`╰${"─".repeat(inner)}╯`,
];
return lines.map((line) => truncateToWidth(line, width));
}
handleInput(data: string) {
if (matchesKey(data, "escape")) this.done();
}
invalidate() {}
dispose() {}
}
确认、取消排队提示和调色板操作都应使用这种确切的组合方式。
模糊筛选列表
Nico 风格的列表筛选方式一致:
- 每输入一个字符就进行筛选
- 筛选内容变化时将选择重置到顶部
- 维护
selectedIndex和scrollOffset - 通过紧凑的进度指示器显示滚动和选择状态
class FuzzyListComponent implements Component, Focusable {
focused = false;
private query = "";
private selectedIndex = 0;
private scrollOffset = 0;
private maxVisible = 8;
private filtered = this.items;
constructor(private items: Array<{ name: string; description: string; model?: string }>) {}
handleInput(data: string): void {
if (matchesKey(data, "up") && this.selectedIndex > 0) this.selectedIndex--;
else if (matchesKey(data, "down") && this.selectedIndex < this.filtered.length - 1) this.selectedIndex++;
else if (data.length === 1 && data.charCodeAt(0) >= 32) this.query += data;
else if (matchesKey(data, "backspace")) this.query = this.query.slice(0, -1);
this.filtered = fuzzyFilter(this.items, this.query);
this.selectedIndex = 0;
const maxStart = Math.max(0, this.filtered.length - this.maxVisible);
this.scrollOffset = Math.min(this.scrollOffset, maxStart);
}
render(width: number): string[] {
const lines = [];
const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filtered.length - this.maxVisible));
const end = Math.min(start + this.maxVisible, this.filtered.length);
for (let i = start; i < end; i++) {
const item = this.filtered[i];
const selected = i === this.selectedIndex ? "▸" : "·";
lines.push(`${selected} ${item.name} ${item.description ?? ""}`);
}
if (this.filtered.length > this.maxVisible) {
lines.push(`${this.selectedIndex + 1}/${this.filtered.length}`);
}
return lines;
}
invalidate() {}
dispose() {}
}
基于片段的渲染
Powerline 风格的状态栏最适合使用独立的片段对象:
interface RenderedSegment {
content: string;
visible: boolean;
}
interface StatusLineSegment {
id: string;
render(ctx: any): RenderedSegment;
}
const SEGMENTS: Record<string, StatusLineSegment> = {
model: {
id: "model",
render(ctx) {
if (!ctx.model?.name) return { content: "", visible: false };
return { content: `🤖 ${ctx.model.name}`, visible: true };
},
},
git: {
id: "git",
render(ctx) {
if (!ctx.git?.branch) return { content: "", visible: false };
return { content: ` ${ctx.git.branch}`, visible: true };
},
},
};
function renderSegment(id: string, ctx: any): RenderedSegment {
return SEGMENTS[id]?.render(ctx) ?? { content: "", visible: false };
}
分别渲染每个片段,然后用语义分隔符连接;每个片段决定自身是否可见(内容为空时设为 visible: false)。
复制粘贴示例
完整、独立的组件实现请参阅 references/examples.md:
- 选择对话框 — SelectList + DynamicBorder + 键盘提示
- 状态仪表盘 — 多分区边框绘制布局、对齐的列、语义颜色
- 进度跟踪器 — 动画点字进度条、计时器生命周期、dispose 清理
- 数据表格 — 可滚动行、列对齐、行高亮
- 持久小组件 — 编辑器上方的健康状态指示器,单行紧凑显示
- 工具渲染器 — 使用 renderCall/renderResult 展示可展开的详情
- 叠加面板 — 侧边面板、响应式显示、锚点定位
上游示例
pi 仓库中的扩展示例展示了更多模式:
- snake.ts:完整的游戏循环、边框绘制、通过
pi.appendEntry()持久化会话 - space-invaders.ts:Kitty 按键释放事件(
wantsKeyRelease)、多实体渲染 - overlay-qa-tests.ts:全部 9 种锚点位置、响应式显示、约 30 FPS 动画
- preset.ts:使用 DynamicBorder 的 SelectList——标准对话框模式
- plan-mode:使用 setStatus + setWidget 显示持久模式指示器
- todo.ts:使用 renderCall/renderResult 自定义工具渲染