pi 可以创建 TUI 组件。让它为你的使用场景构建一个。
TUI 组件
扩展和自定义工具可以渲染自定义 TUI 组件,用于交互式用户界面。本页介绍组件系统和可用构建块。
Component 接口
所有组件都实现:
interface Component {
render(width: number): string[]
handleInput?(data: string): void
wantsKeyRelease?: boolean
invalidate(): void
}
| 方法 | 描述 |
|---|---|
render(width) | 返回字符串数组(每行一个字符串)。每行不得超过 width。 |
handleInput?(data) | 当组件获得焦点时接收键盘输入。 |
wantsKeyRelease? | 如果为 true,组件会接收按键释放事件(Kitty 协议)。默认:false。 |
invalidate() | 清除缓存的渲染状态。在主题变化时调用。 |
TUI 会在每个已渲染行的末尾追加完整的 SGR reset 和 OSC 8 reset。样式不会跨行保留。如果你输出带样式的多行文本,请逐行重新应用样式,或使用 wrapTextWithAnsi(),以便每个换行后的行都保留样式。
Focusable 接口(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) 或 PI_HARDWARE_CURSOR=1 启用。内置的 Editor 和 Input 组件已经实现了该接口。
带嵌入输入框的容器组件
当容器组件(对话框、选择器等)包含 Input 或 Editor 子组件时,该容器必须实现 Focusable 并将焦点状态传递给子组件。否则,硬件光标将无法为 IME 输入正确定位。
import { Container, type Focusable, Input } from '@earendil-works/pi-tui'
class SearchDialog extends Container implements Focusable {
private searchInput: Input
// Focusable implementation - propagate 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(中文、日文、韩文等)输入时,候选窗口会显示在屏幕上的错误位置。
使用组件
在扩展中通过 ctx.ui.custom():
pi.on('session_start', async (_event, ctx) => {
const handle = ctx.ui.custom(myComponent)
// handle.requestRender() - trigger re-render
// handle.close() - restore normal UI
})
在自定义工具中通过 pi.ui.custom():
async execute(toolCallId, params, onUpdate, ctx, signal) {
const handle = pi.ui.custom(myComponent);
// ...
handle.close();
}
覆盖层
覆盖层会在不清屏的情况下,将组件渲染到现有内容之上。向 ctx.ui.custom() 传入 { overlay: true }:
const result = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
{ overlay: true },
)
定位和尺寸请使用 overlayOptions:
const result = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) => new SidePanel({ onClose: done }),
{
overlay: true,
overlayOptions: {
// Size: number or percentage string
width: '50%', // 50% of terminal width
minWidth: 40, // minimum 40 columns
maxHeight: '80%', // max 80% of terminal height
// Position: anchor-based (default: "center")
anchor: 'right-center', // 9 positions: center, top-left, top-center, etc.
offsetX: -2, // offset from anchor
offsetY: 0,
// Or percentage/absolute positioning
row: '25%', // 25% from top
col: 10, // column 10
// Margins
margin: 2, // all sides, or { top, right, bottom, left }
// Responsive: hide on narrow terminals
visible: (termWidth, termHeight) => termWidth >= 80,
},
// Get handle for programmatic focus and visibility control
onHandle: (handle) => {
// handle.focus() - focus this overlay and bring it to the visual front
// handle.unfocus() - release input to normal fallback
// handle.unfocus({ target }) - release input to a specific component or null
// handle.setHidden(true/false) - toggle visibility
// handle.hide() - permanently remove
},
},
)
覆盖层焦点
获得焦点且可见的覆盖层会在临时非覆盖层 UI 期间保持输入所有权。如果某个覆盖层打开另一个没有 { overlay: true } 的 ctx.ui.custom() 组件,该替换 UI 在活动期间会接收输入;当它关闭时,获得焦点的覆盖层可以重新取得输入。
当可见覆盖层应停止拥有输入、并让 TUI 回退到另一个可见的捕获覆盖层或上一个焦点目标时,请使用 handle.unfocus()。当覆盖层保持可见但某个特定组件应接收输入时,请使用 handle.unfocus({ target })。传入 { target: null } 会有意让当前没有获得焦点的组件,直到再次设置焦点。
覆盖层生命周期
覆盖层组件在关闭时会被释放。不要复用引用——请创建新的实例:
// Wrong - stale reference
let menu: MenuComponent
await ctx.ui.custom(
(_, __, ___, done) => {
menu = new MenuComponent(done)
return menu
},
{ overlay: true },
)
setActiveComponent(menu) // Disposed
// Correct - re-call to re-show
const showMenu = () =>
ctx.ui.custom((_, __, ___, done) => new MenuComponent(done), { overlay: true })
await showMenu() // First show
await showMenu() // "Back" = just call again
有关涵盖锚点、边距、堆叠、响应式可见性和动画的完整示例,请参阅 overlay-qa-tests.ts。
内置组件
从 @earendil-works/pi-tui 导入:
import { Text, Box, Container, Spacer, Markdown } from '@earendil-works/pi-tui'
Text
带自动换行的多行文本。
const text = new Text(
'Hello World', // content
1, // paddingX (default: 1)
1, // paddingY (default: 1)
(s) => bgGray(s), // optional background function
)
text.setText('Updated')
Box
带内边距和背景色的容器。
const box = new Box(
1, // paddingX
1, // paddingY
(s) => bgGray(s), // background function
)
box.addChild(new Text('Content', 0, 0))
box.setBgFn((s) => bgBlue(s))
Container
垂直组合子组件。
const container = new Container()
container.addChild(component1)
container.addChild(component2)
container.removeChild(component1)
Spacer
空的垂直空间。
const spacer = new Spacer(2) // 2 empty lines
Markdown
渲染带语法高亮的 markdown。
const md = new Markdown(
'# Title\n\nSome **bold** text',
1, // paddingX
1, // paddingY
theme, // MarkdownTheme (see below)
)
md.setText('Updated markdown')
Image
在支持的终端(Kitty、iTerm2、Ghostty、WezTerm、Warp)中渲染图片。
const image = new Image(
base64Data, // base64-encoded image
'image/png', // MIME type
theme, // ImageTheme
{ maxWidthCells: 80, maxHeightCells: 24 },
)
键盘输入
使用 matchesKey() 进行按键检测:
import { matchesKey, Key } from "@earendil-works/pi-tui";
handleInput(data: string) {
if (matchesKey(data, Key.up)) {
this.selectedIndex--;
} else if (matchesKey(data, Key.enter)) {
this.onSelect?.(this.selectedIndex);
} else if (matchesKey(data, Key.escape)) {
this.onCancel?.();
} else if (matchesKey(data, Key.ctrl("c"))) {
// Ctrl+C
}
}
按键标识符(使用 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"
行宽
关键: render() 返回的每一行都不得超过 width 参数。
import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
render(width: number): string[] {
// Truncate long lines
return [truncateToWidth(this.text, width)];
}
工具函数:
visibleWidth(str)- 获取显示宽度(忽略 ANSI 代码)truncateToWidth(str, width, ellipsis?)- 截断,可选省略号wrapTextWithAnsi(str, width)- 保留 ANSI 代码的自动换行
创建自定义组件
示例:交互式选择器
import { matchesKey, Key, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'
class MySelector {
private items: string[]
private selected = 0
private cachedWidth?: number
private cachedLines?: string[]
public onSelect?: (item: string) => void
public onCancel?: () => void
constructor(items: string[]) {
this.items = items
}
handleInput(data: string): void {
if (matchesKey(data, Key.up) && this.selected > 0) {
this.selected--
this.invalidate()
} else if (matchesKey(data, Key.down) && this.selected < this.items.length - 1) {
this.selected++
this.invalidate()
} else if (matchesKey(data, Key.enter)) {
this.onSelect?.(this.items[this.selected])
} else if (matchesKey(data, Key.escape)) {
this.onCancel?.()
}
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines
}
this.cachedLines = this.items.map((item, i) => {
const prefix = i === this.selected ? '> ' : ' '
return truncateToWidth(prefix + item, width)
})
this.cachedWidth = width
return this.cachedLines
}
invalidate(): void {
this.cachedWidth = undefined
this.cachedLines = undefined
}
}
在扩展中使用:
pi.registerCommand('pick', {
description: 'Pick an item',
handler: async (args, ctx) => {
const items = ['Option A', 'Option B', 'Option C']
const selector = new MySelector(items)
let handle: { close: () => void; requestRender: () => void }
await new Promise<void>((resolve) => {
selector.onSelect = (item) => {
ctx.ui.notify(`Selected: ${item}`, 'info')
handle.close()
resolve()
}
selector.onCancel = () => {
handle.close()
resolve()
}
handle = ctx.ui.custom(selector)
})
},
})
主题
组件接受主题对象用于样式。
在 renderCall/renderResult 中,使用 theme 参数:
renderResult(result, options, theme, context) {
// Use theme.fg() for foreground colors
return new Text(theme.fg("success", "Done!"), 0, 0);
// Use theme.bg() for background colors
const styled = theme.bg("toolPendingBg", theme.fg("accent", "text"));
}
前景色(theme.fg(color, text)):
| 类别 | 颜色 |
|---|---|
| 通用 | text, accent, muted, dim |
| 状态 | success, error, warning |
| 边框 | border, borderAccent, borderMuted |
| 消息 | userMessageText, customMessageText, customMessageLabel |
| 工具 | toolTitle, toolOutput |
| Diff | toolDiffAdded, toolDiffRemoved, toolDiffContext |
| Markdown | mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet |
| 语法 | syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation |
| Thinking | thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh |
| 模式 | bashMode |
背景色(theme.bg(color, text)):
selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg
用于 Markdown,使用 getMarkdownTheme():
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
import { Markdown } from "@earendil-works/pi-tui";
renderResult(result, options, theme, context) {
const mdTheme = getMarkdownTheme();
return new Markdown(result.details.markdown, 0, 0, mdTheme);
}
用于自定义组件,定义你自己的主题接口:
interface MyTheme {
selected: (s: string) => string
normal: (s: string) => string
}
调试日志
设置 PI_TUI_WRITE_LOG 来捕获写入 stdout 的原始 ANSI 流。
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.ts
性能
尽可能缓存渲染输出:
class CachedComponent {
private cachedWidth?: number
private cachedLines?: string[]
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines
}
// ... compute lines ...
this.cachedWidth = width
this.cachedLines = lines
return lines
}
invalidate(): void {
this.cachedWidth = undefined
this.cachedLines = undefined
}
}
当状态变化时调用 invalidate(),然后调用 handle.requestRender() 触发重新渲染。
失效与主题变化
主题变化时,TUI 会在所有组件上调用 invalidate() 来清除缓存。组件必须正确实现 invalidate(),以确保主题变化生效。
问题
如果组件将主题颜色预先烘焙进字符串(通过 theme.fg()、theme.bg() 等)并缓存它们,缓存字符串会包含旧主题的 ANSI 转义代码。如果组件单独存储了带主题的内容,仅清除渲染缓存是不够的。
错误做法(主题颜色不会更新):
class BadComponent extends Container {
private content: Text
constructor(message: string, theme: Theme) {
super()
// Pre-baked theme colors stored in Text component
this.content = new Text(theme.fg('accent', message), 1, 0)
this.addChild(this.content)
}
// No invalidate override - parent's invalidate only clears
// child render caches, not the pre-baked content
}
解决方案
使用主题颜色构建内容的组件必须在调用 invalidate() 时重建该内容:
class GoodComponent extends Container {
private message: string
private content: Text
constructor(message: string) {
super()
this.message = message
this.content = new Text('', 1, 0)
this.addChild(this.content)
this.updateDisplay()
}
private updateDisplay(): void {
// Rebuild content with current theme
this.content.setText(theme.fg('accent', this.message))
}
override invalidate(): void {
super.invalidate() // Clear child caches
this.updateDisplay() // Rebuild with new theme
}
}
模式:在失效时重建
对于具有复杂内容的组件:
class ComplexComponent extends Container {
private data: SomeData
constructor(data: SomeData) {
super()
this.data = data
this.rebuild()
}
private rebuild(): void {
this.clear() // Remove all children
// Build UI with current theme
this.addChild(new Text(theme.fg('accent', theme.bold('Title')), 1, 0))
this.addChild(new Spacer(1))
for (const item of this.data.items) {
const color = item.active ? 'success' : 'muted'
this.addChild(new Text(theme.fg(color, item.label), 1, 0))
}
}
override invalidate(): void {
super.invalidate()
this.rebuild()
}
}
何时重要
在以下情况下需要此模式:
- 预先烘焙主题颜色 - 使用
theme.fg()或theme.bg()创建存储在子组件中的带样式字符串 - 语法高亮 - 使用
highlightCode(),它会应用基于主题的语法颜色 - 复杂布局 - 构建嵌入主题颜色的子组件树
在以下情况下不需要此模式:
- 使用主题回调 - 传入像
(text) => theme.fg("accent", text)这样的函数,并在渲染期间调用 - 简单容器 - 只是组合其他组件,不添加带主题的内容
- 无状态渲染 - 在每次
render()调用中重新计算带主题的输出(不缓存)
常见模式
这些模式涵盖扩展中最常见的 UI 需求。复制这些模式,而不是从零开始构建。
模式 1:选择对话框(SelectList)
用于让用户从选项列表中选择。使用来自 @earendil-works/pi-tui 的 SelectList,并用 DynamicBorder 加框。
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
import { DynamicBorder } from '@earendil-works/pi-coding-agent'
import { Container, type SelectItem, SelectList, Text } from '@earendil-works/pi-tui'
pi.registerCommand('pick', {
handler: async (_args, ctx) => {
const items: SelectItem[] = [
{ value: 'opt1', label: 'Option 1', description: 'First option' },
{ value: 'opt2', label: 'Option 2', description: 'Second option' },
{ value: 'opt3', label: 'Option 3' }, // description is optional
]
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
const container = new Container()
// Top border
container.addChild(new DynamicBorder((s: string) => theme.fg('accent', s)))
// Title
container.addChild(new Text(theme.fg('accent', theme.bold('Pick an Option')), 1, 0))
// SelectList with theme
const selectList = new SelectList(items, Math.min(items.length, 10), {
selectedPrefix: (t) => theme.fg('accent', t),
selectedText: (t) => theme.fg('accent', t),
description: (t) => theme.fg('muted', t),
scrollInfo: (t) => theme.fg('dim', t),
noMatch: (t) => theme.fg('warning', t),
})
selectList.onSelect = (item) => done(item.value)
selectList.onCancel = () => done(null)
container.addChild(selectList)
// Help text
container.addChild(new Text(theme.fg('dim', '↑↓ navigate • enter select • esc cancel'), 1, 0))
// Bottom border
container.addChild(new DynamicBorder((s: string) => theme.fg('accent', s)))
return {
render: (w) => container.render(w),
invalidate: () => container.invalidate(),
handleInput: (data) => {
selectList.handleInput(data)
tui.requestRender()
},
}
})
if (result) {
ctx.ui.notify(`Selected: ${result}`, 'info')
}
},
})
模式 2:可取消的异步操作(BorderedLoader)
用于耗时且应可取消的操作。BorderedLoader 会显示 spinner,并处理 escape 以取消。
import { BorderedLoader } from '@earendil-works/pi-coding-agent'
pi.registerCommand('fetch', {
handler: async (_args, ctx) => {
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
const loader = new BorderedLoader(tui, theme, 'Fetching data...')
loader.onAbort = () => done(null)
// Do async work
fetchData(loader.signal)
.then((data) => done(data))
.catch(() => done(null))
return loader
})
if (result === null) {
ctx.ui.notify('Cancelled', 'info')
} else {
ctx.ui.setEditorText(result)
}
},
})
示例: qna.ts, handoff.ts
模式 3:设置/开关(SettingsList)
用于切换多个设置。使用来自 @earendil-works/pi-tui 的 SettingsList 和 getSettingsListTheme()。
import { getSettingsListTheme } from '@earendil-works/pi-coding-agent'
import { Container, type SettingItem, SettingsList, Text } from '@earendil-works/pi-tui'
pi.registerCommand('settings', {
handler: async (_args, ctx) => {
const items: SettingItem[] = [
{ id: 'verbose', label: 'Verbose mode', currentValue: 'off', values: ['on', 'off'] },
{ id: 'color', label: 'Color output', currentValue: 'on', values: ['on', 'off'] },
]
await ctx.ui.custom((_tui, theme, _kb, done) => {
const container = new Container()
container.addChild(new Text(theme.fg('accent', theme.bold('Settings')), 1, 1))
const settingsList = new SettingsList(
items,
Math.min(items.length + 2, 15),
getSettingsListTheme(),
(id, newValue) => {
// Handle value change
ctx.ui.notify(`${id} = ${newValue}`, 'info')
},
() => done(undefined), // On close
{ enableSearch: true }, // Optional: enable fuzzy search by label
)
container.addChild(settingsList)
return {
render: (w) => container.render(w),
invalidate: () => container.invalidate(),
handleInput: (data) => settingsList.handleInput?.(data),
}
})
},
})
示例: tools.ts
模式 4:持久状态指示器
在页脚中显示跨渲染持久存在的状态。适合模式指示器。
// Set status (shown in footer)
ctx.ui.setStatus('my-ext', ctx.ui.theme.fg('accent', '● active'))
// Clear status
ctx.ui.setStatus('my-ext', undefined)
示例: status-line.ts, plan-mode/index.ts, preset.ts
模式 4b:工作指示器自定义
自定义 pi 流式输出响应时显示的内联工作指示器。
// Static indicator
ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg('accent', '●')] })
// Custom animated indicator
ctx.ui.setWorkingIndicator({
frames: [
ctx.ui.theme.fg('dim', '·'),
ctx.ui.theme.fg('muted', '•'),
ctx.ui.theme.fg('accent', '●'),
ctx.ui.theme.fg('muted', '•'),
],
intervalMs: 120,
})
// Hide the indicator entirely
ctx.ui.setWorkingIndicator({ frames: [] })
// Restore pi's default spinner
ctx.ui.setWorkingIndicator()
这只影响普通流式工作指示器。压缩和重试加载器保留其内置样式。自定义帧会逐字渲染,因此扩展必须在需要时自行添加颜色。
模式 5:编辑器上方/下方的小组件
在输入编辑器上方或下方显示持久内容。适合待办列表、进度。
// Simple string array (above editor by default)
ctx.ui.setWidget('my-widget', ['Line 1', 'Line 2'])
// Render below the editor
ctx.ui.setWidget('my-widget', ['Line 1', 'Line 2'], { placement: 'belowEditor' })
// Or with theme
ctx.ui.setWidget('my-widget', (_tui, theme) => {
const lines = items.map((item, i) =>
item.done
? theme.fg('success', '✓ ') + theme.fg('muted', item.text)
: theme.fg('dim', '○ ') + item.text,
)
return {
render: () => lines,
invalidate: () => {},
}
})
// Clear
ctx.ui.setWidget('my-widget', undefined)
模式 6:自定义页脚
替换页脚。footerData 暴露扩展无法通过其他方式访问的数据。
ctx.ui.setFooter((tui, theme, footerData) => ({
invalidate() {},
render(width: number): string[] {
// footerData.getGitBranch(): string | null
// footerData.getExtensionStatuses(): ReadonlyMap<string, string>
return [`${ctx.model?.id} (${footerData.getGitBranch() || 'no git'})`]
},
dispose: footerData.onBranchChange(() => tui.requestRender()), // reactive
}))
ctx.ui.setFooter(undefined) // restore default
可通过 ctx.sessionManager.getBranch() 和 ctx.model 获取 token 统计。
示例: custom-footer.ts
模式 7:自定义编辑器(vim 模式等)
用自定义实现替换主输入编辑器。适用于模态编辑(vim)、不同键绑定(emacs)或专用输入处理。
import { CustomEditor, type ExtensionAPI } from '@earendil-works/pi-coding-agent'
import { matchesKey, truncateToWidth } from '@earendil-works/pi-tui'
type Mode = 'normal' | 'insert'
class VimEditor extends CustomEditor {
private mode: Mode = 'insert'
handleInput(data: string): void {
// Escape: switch to normal mode, or pass through for app handling
if (matchesKey(data, 'escape')) {
if (this.mode === 'insert') {
this.mode = 'normal'
return
}
// In normal mode, escape aborts agent (handled by CustomEditor)
super.handleInput(data)
return
}
// Insert mode: pass everything to CustomEditor
if (this.mode === 'insert') {
super.handleInput(data)
return
}
// Normal mode: vim-style navigation
switch (data) {
case 'i':
this.mode = 'insert'
return
case 'h':
super.handleInput('\x1b[D')
return // Left
case 'j':
super.handleInput('\x1b[B')
return // Down
case 'k':
super.handleInput('\x1b[A')
return // Up
case 'l':
super.handleInput('\x1b[C')
return // Right
}
// Pass unhandled keys to super (ctrl+c, etc.), but filter printable chars
if (data.length === 1 && data.charCodeAt(0) >= 32) return
super.handleInput(data)
}
render(width: number): string[] {
const lines = super.render(width)
// Add mode indicator to bottom border (use truncateToWidth for ANSI-safe truncation)
if (lines.length > 0) {
const label = this.mode === 'normal' ? ' NORMAL ' : ' INSERT '
const lastLine = lines[lines.length - 1]!
// Pass "" as ellipsis to avoid adding "..." when truncating
lines[lines.length - 1] = truncateToWidth(lastLine, width - label.length, '') + label
}
return lines
}
}
export default function (pi: ExtensionAPI) {
pi.on('session_start', (_event, ctx) => {
// Factory receives theme and keybindings from the app
ctx.ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(theme, keybindings))
})
}
要点:
- 继承
CustomEditor(而不是基础Editor),以获得应用键绑定(escape 中止、ctrl+d 退出、模型切换等) - 对你不处理的按键调用
super.handleInput(data) - 工厂模式:
setEditorComponent接收一个工厂函数,该函数获得tui、theme和keybindings - 传入
undefined以恢复默认编辑器:ctx.ui.setEditorComponent(undefined)
示例: modal-editor.ts
关键规则
始终使用回调中的主题 - 不要直接导入 theme。使用
ctx.ui.custom((tui, theme, keybindings, done) => ...)回调中的theme。始终标注 DynamicBorder 颜色参数类型 - 写
(s: string) => theme.fg("accent", s),不要写(s) => theme.fg("accent", s)。状态变化后调用 tui.requestRender() - 在
handleInput中,更新状态后调用tui.requestRender()。返回包含三个方法的对象 - 自定义组件需要
{ render, invalidate, handleInput }。使用现有组件 -
SelectList、SettingsList、BorderedLoader覆盖 90% 的场景。不要重新构建它们。
示例
- 选择 UI:examples/extensions/preset.ts - 带 DynamicBorder 框架的 SelectList
- 可取消异步:examples/extensions/qna.ts - 用于 LLM 调用的 BorderedLoader
- 设置开关:examples/extensions/tools.ts - 用于工具启用/禁用的 SettingsList
- 状态指示器:examples/extensions/plan-mode/index.ts - setStatus 和 setWidget
- 工作指示器:examples/extensions/working-indicator.ts - setWorkingIndicator
- 自定义页脚:examples/extensions/custom-footer.ts - 带统计信息的 setFooter
- 自定义编辑器:examples/extensions/modal-editor.ts - 类 Vim 的模态编辑
- 贪吃蛇游戏:examples/extensions/snake.ts - 带键盘输入和游戏循环的完整游戏
- 自定义工具渲染:examples/extensions/todo.ts - renderCall 和 renderResult