背景
用 pi 写代码时,经常需要切出去看一眼文件(yazi)、提交代码(lazygit / gcp)、或者临时跑个命令(fish)。每次都 Ctrl+Z 挂起 pi 或者另开一个终端窗口很打断思路。
理想的状态是:按一个键,在 pi 上层弹出一个真正的终端小窗,用完即关——像 Neovim 的浮动终端(:term / :Floaterm),但更通用、更顺手。
之前写过 pi 的 CustomEditor 扩展把光标改成竖线,又写了 git-changes 扩展利用 overlay 查看文件变更。这次更进一步:利用 pi 的 overlay 框架,结合 node-pty 实现真正的 PTY 浮动终端,并且内置一个完整的 ANSI 终端模拟器(TermBuffer)来渲染输出。
直接体验
你可以直接运行下面的命令安装本文的插件的最新版本,下文的描述可能会有过期
| |
需求
- 一键唤起——常用工具一个快捷键,无需输入命令,其他工具在通用终端fish中按需要唤起
- 真正的终端——不是
spawn+ 抓 stdout,而是 PTY,支持交互式 TUI 程序(lazygit、yazi、vim、htop 等) - 不占用主 UI——浮动 overlay,查看/操作完即关,pi 主界面不受影响
- 完整的终端渲染——支持颜色、光标定位、alternate screen(全屏 TUI 程序)、CJK/emoji 宽字符
- 快速关闭——
Ctrl+Space强制关闭,exit/Ctrl+D正常退出 - 使用lazygit 在pi内部跟踪文件变动情况
快捷键与命令
| 快捷键 | 命令 | 功能 |
|---|---|---|
Ctrl+Alt+F | /float-fish | 浮动终端:fish shell |
Ctrl+Alt+G | /float-lazygit | 浮动终端:lazygit |
Ctrl+Alt+Y | /float-yazi | 浮动终端:yazi 文件管理器 |
Ctrl+Shift+Alt+G | — | 浮动终端:gcp(conventional commits) |
状态栏常驻显示快捷键提示:
| |
实现方法
整体架构
| |
关键 API
| API | 用途 |
|---|---|
pi.registerShortcut("ctrl+alt+f", ...) | 全局快捷键,一键唤起浮动终端 |
pi.registerCommand("float-fish", ...) | 命令入口,/float-fish 唤起 |
ctx.ui.custom(component, { overlay: true }) | 浮动 overlay,叠加在主 UI 之上 |
ctx.ui.setStatus(key, text) | footer 状态栏常驻快捷键提示 |
node-pty (ptySpawn) | 创建真正的 PTY 伪终端进程 |
matchesKey(data, Key.ctrl(" ")) | 检测 Ctrl+Space 强制关闭 |
node-pty:真正的 PTY 终端
和 child_process.spawn 不同,node-pty 创建的是 伪终端(PTY)。区别在于:
spawn | node-pty | |
|---|---|---|
| 输出 | 管道(pipe),无终端特性 | PTY,程序以为自己在真实终端里 |
| TUI 程序 | ❌ lazygit / vim / htop 无法运行 | ✅ 完全支持 |
| ANSI 转义 | 程序通常不输出 | 程序正常输出颜色、光标控制、alternate screen |
| resize | 不支持 | ✅ pty.resize(cols, rows) |
| |
TermBuffer:ANSI 终端模拟器
因为 pi 的 overlay 渲染是基于行数组(render(): string[])的,无法直接使用终端的原生渲染。所以需要一个 ANSI 终端模拟器 把 PTY 输出的 ANSI 转义序列解析成 Cell 网格,再重新转成带颜色的 ANSI 字符串。
实现了一个精简但功能完备的终端模拟器,包含:
CSI 序列支持
| 序列 | 功能 |
|---|---|
CSI n A/B/C/D | 光标上/下/右/左移动 |
CSI n;m H / CSI n;m f | 光标定位(CUP) |
CSI n J | 擦除显示(0=光标后, 1=光标前, 2/3=全屏) |
CSI n K | 擦除行(0=光标后, 1=光标前, 2=整行) |
CSI s / CSI u | 保存/恢复光标位置和属性 |
CSI ?25 h/l | 光标显示/隐藏 |
CSI ?1049 h/l / CSI ?47 h/l | Alternate Screen 切换 |
SGR 渲染(Select Graphic Rendition)
支持 ANSI 256 色、True Color(RGB→256 映射)、粗体、斜体、下划线、反视频等属性。
反视频(reverse video)的处理很关键:pi 的 overlay 背景是透明的,叠加在终端上。如果直接输出 \x1b[7m(让终端自己做反视频),可能和显式颜色冲突导致闪烁。TermBuffer 的做法是在渲染时手动交换 fg/bg 颜色,而不是输出 \x1b[7m:
| |
Alternate Screen(全屏程序支持)
lazygit、vim、less 等程序会切换终端的 alternate screen buffer(\x1b[?1049h),TermBuffer 完整实现了双缓冲切换:
| |
退出时恢复主屏幕(\x1b[?1049l),内容完整还原。
终端查询响应
有些程序启动时会查询终端能力,如果不响应会导致卡死或显示异常:
| 查询序列 | 含义 | 响应 |
|---|---|---|
CSI c | Primary DA(设备属性) | CSI ?1;2c(VT100 + AVO) |
CSI > c | Secondary DA | CSI >0;0;0c |
CSI 6 n | CPR(光标位置报告) | CSI row;col R |
CSI 5 n | DSR(设备状态报告) | CSI 0n |
CJK / Emoji 宽字符
中日韩字符和 emoji 占 2 个终端列宽。TermBuffer 用 Unicode 码点范围判断宽度,渲染时宽字符占一个 Cell 并标记下一个 Cell 为空占位符:
| |
浮动框渲染
浮动框使用 Unicode box-drawing 字符绘制边框(╭╮╰╯│─),紫色(mauve)边框:
- 内容区域:TermBuffer 渲染的行数组,每行的 ANSI 颜色被保留
- 底部提示栏:居中显示
Ctrl+Space: close | exit / Ctrl+D: quit shell - 尺寸:宽 85%,高 75%,居中锚定
| |
叠加层支持终端 resize:当 pi 窗口大小变化时,overlay 重新计算内部行列数,同步调用 pty.resize(cols, rows) 通知 PTY 进程。
完整代码
https://github.com/joyanhui/pi-extension/tree/main/pi-ext-float-term
效果
浮动终端界面
| |
状态栏提示
| |
操作速查
| 按键 | 效果 |
|---|---|
Ctrl+Alt+F | 唤起 fish 浮动终端 |
Ctrl+Alt+G | 唤起 lazygit |
Ctrl+Alt+Y | 唤起 yazi 文件管理器 |
Ctrl+Shift+Alt+G | 唤起 gcp(conventional commits) |
/float-fish | 命令方式唤起 fish |
/float-lazygit | 命令方式唤起 lazygit |
/float-yazi | 命令方式唤起 yazi |
Ctrl+Space | 强制关闭浮动终端 |
exit / Ctrl+D | 正常退出 |
总结
这次扩展是 pi 插件体系中最复杂的之一,核心挑战在于:
- PTY vs Pipe——只有 PTY 才能让交互式 TUI 程序(lazygit、yazi、vim)正常工作,
child_process.spawn的 pipe 模式不行 - ANSI 终端模拟器——因为 pi 的 overlay 渲染框架是行数组接口,必须把 PTY 输出重新解析成 Cell 网格再渲染。自己实现一个精简的终端模拟器虽然工作量大,但规避了对
xterm.js等重型库的依赖 - Alternate Screen——全屏 TUI 程序依赖 alternate screen buffer,TermBuffer 的双缓冲机制完整支持了切换和恢复
- 反视频渲染——
\x1b[7m在 overlay 叠加场景下有特殊处理,通过手动交换 fg/bg 颜色避免闪烁
结合 bar-cursor 扩展 和 git-changes 扩展,pi 的三个扩展覆盖了光标美化、工作区状态、终端集成三个维度,全部通过 ~/.pi/agent/extensions/ 目录热加载,配合 NixOS home-manager 管理软链接,配置可追溯、可复现。
TypeScript 扩展的开发体验很好——放到目录就自动加载,/reload 热更新,调试迭代很快。