import{CURSOR_MARKER,typeComponent,typeFocusable}from"@earendil-works/pi-tui";classMyInputimplementsComponent,Focusable{focused: boolean=false;// Set by TUI when focus changes
render(width: number):string[]{constmarker=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 在创建渲染器之前还会把 PI_HARDWARE_CURSOR=1 映射到该设置。内置的 Editor 和 Input 组件已经实现了该接口。
内嵌输入组件的容器
当容器组件(对话框、选择器等)包含 Input 或 Editor 子组件时,容器必须实现 Focusable,并把焦点状态传递给子组件。否则,硬件光标将无法为 IME 输入正确定位。
import{Container,typeFocusable,Input}from"@earendil-works/pi-tui";classSearchDialogextendsContainerimplementsFocusable{privatesearchInput: Input;// Focusable implementation - propagate to child input for IME cursor positioning
private_focused=false;getfocused():boolean{returnthis._focused;}setfocused(value: boolean){this._focused=value;this.searchInput.focused=value;}constructor(){super();this.searchInput=newInput();this.addChild(this.searchInput);}}
asyncexecute(toolCallId,params,signal,onUpdate,ctx){constresult=awaitctx.ui.custom<string|null>((tui,theme,keybindings,done)=>newMyComponent({theme,keybindings,onChange:()=>tui.requestRender(),onSelect:(value)=>done(value),onCancel:()=>done(null),}));// Use result...
}
constresult=awaitctx.ui.custom<string|null>((tui,theme,keybindings,done)=>newSidePanel({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
},});
// Wrong - stale reference
letmenu: MenuComponent;awaitctx.ui.custom((_,__,___,done)=>{menu=newMenuComponent(done);returnmenu;},{overlay: true});setActiveComponent(menu);// Disposed
// Correct - re-call to re-show
constshowMenu=()=>ctx.ui.custom((_,__,___,done)=>newMenuComponent(done),{overlay: true});awaitshowMenu();// First show
awaitshowMenu();// "Back" = just call again
pi.registerCommand("pick",{description:"Pick an item",handler: async(_args,ctx)=>{constitems=["Option A","Option B","Option C"];constselected=awaitctx.ui.custom<string|null>((tui,_theme,_keybindings,done)=>{constselector=newMySelector(items);selector.onSelect=done;selector.onCancel=()=>done(null);return{render:(width)=>selector.render(width),handleInput:(data)=>{selector.handleInput(data);tui.requestRender();},invalidate:()=>selector.invalidate(),};});if(selected!==null){ctx.ui.notify(`Selected: ${selected}`,"info");}}});
主题化
组件接受主题对象来进行样式设置。
在 renderCall/renderResult 中,使用 theme 参数:
renderResult(result,options,theme,context){// Use theme.fg() for foreground colors
returnnewText(theme.fg("success","Done!"),0,0);// Use theme.bg() for background colors
conststyled=theme.bg("toolPendingBg",theme.fg("accent","text"));}
classBadComponentextendsContainer{privatecontent: Text;constructor(message: string,theme: Theme){super();// Pre-baked theme colors stored in Text component
this.content=newText(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() 被调用时重建该内容:
classGoodComponentextendsContainer{privatemessage: string;privatecontent: Text;constructor(message: string){super();this.message=message;this.content=newText("",1,0);this.addChild(this.content);this.updateDisplay();}privateupdateDisplay():void{// Rebuild content with current theme
this.content.setText(theme.fg("accent",this.message));}overrideinvalidate():void{super.invalidate();// Clear child caches
this.updateDisplay();// Rebuild with new theme
}}
模式:失效时重建
对于内容复杂的组件:
classComplexComponentextendsContainer{privatedata: SomeData;constructor(data: SomeData){super();this.data=data;this.rebuild();}privaterebuild():void{this.clear();// Remove all children
// Build UI with current theme
this.addChild(newText(theme.fg("accent",theme.bold("Title")),1,0));this.addChild(newSpacer(1));for(constitemofthis.data.items){constcolor=item.active?"success":"muted";this.addChild(newText(theme.fg(color,item.label),1,0));}}overrideinvalidate():void{super.invalidate();this.rebuild();}}
importtype{ExtensionAPI}from"@earendil-works/pi-coding-agent";import{DynamicBorder}from"@earendil-works/pi-coding-agent";import{Container,typeSelectItem,SelectList,Text}from"@earendil-works/pi-tui";pi.registerCommand("pick",{handler: async(_args,ctx)=>{constitems: 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
];constresult=awaitctx.ui.custom<string|null>((tui,theme,_kb,done)=>{constcontainer=newContainer();// Top border
container.addChild(newDynamicBorder((s: string)=>theme.fg("accent",s)));// Title
container.addChild(newText(theme.fg("accent",theme.bold("Pick an Option")),1,0));// SelectList with theme
constselectList=newSelectList(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(newText(theme.fg("dim","↑↓ navigate • enter select • esc cancel"),1,0));// Bottom border
container.addChild(newDynamicBorder((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");}},});
import{BorderedLoader}from"@earendil-works/pi-coding-agent";pi.registerCommand("fetch",{handler: async(_args,ctx)=>{constresult=awaitctx.ui.custom<string|null>((tui,theme,_kb,done)=>{constloader=newBorderedLoader(tui,theme,"Fetching data...");loader.onAbort=()=>done(null);// Do async work
fetchData(loader.signal).then((data)=>done(data)).catch(()=>done(null));returnloader;});if(result===null){ctx.ui.notify("Cancelled","info");}else{ctx.ui.setEditorText(result);}},});
import{CustomEditor,typeExtensionAPI}from"@earendil-works/pi-coding-agent";import{matchesKey,truncateToWidth}from"@earendil-works/pi-tui";typeMode="normal"|"insert";classVimEditorextendsCustomEditor{privatemode: 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[]{constlines=super.render(width);// Add mode indicator to bottom border (use truncateToWidth for ANSI-safe truncation)
if(lines.length>0){constlabel=this.mode==="normal"?" NORMAL ":" INSERT ";constlastLine=lines[lines.length-1]!;// Pass "" as ellipsis to avoid adding "..." when truncating
lines[lines.length-1]=truncateToWidth(lastLine,width-label.length,"")+label;}returnlines;}}exportdefaultfunction(pi: ExtensionAPI){pi.on("session_start",(_event,ctx)=>{// Factory receives the TUI, theme, and keybindings from the app
ctx.ui.setEditorComponent((tui,theme,keybindings)=>newVimEditor(tui,theme,keybindings));});}