小类随手记

pi 插件:浮动终端(float-term)——在 pi 内随时唤起 fish / lazygit / yazi等工具

基于 node-pty + overlay 实现浮动终端,支持 Ctrl+Alt+F/G/Y 快捷键在 pi 内直接唤起 fish、lazygit、yazi、gcp 等工具,内置完整 ANSI 终端模拟器,支持 alternate screen、CJK/emoji 宽字符。

背景

pi 写代码时,经常需要切出去看一眼文件(yazi)、提交代码(lazygit / gcp)、或者临时跑个命令(fish)。每次都 Ctrl+Z 挂起 pi 或者另开一个终端窗口很打断思路。

理想的状态是:按一个键,在 pi 上层弹出一个真正的终端小窗,用完即关——像 Neovim 的浮动终端(:term / :Floaterm),但更通用、更顺手。

之前写过 pi 的 CustomEditor 扩展把光标改成竖线,又写了 git-changes 扩展利用 overlay 查看文件变更。这次更进一步:利用 pi 的 overlay 框架,结合 node-pty 实现真正的 PTY 浮动终端,并且内置一个完整的 ANSI 终端模拟器(TermBuffer)来渲染输出。

直接体验

你可以直接运行下面的命令安装本文的插件的最新版本,下文的描述可能会有过期

1
pi install npm:@joyanhui/pi-ext-float-term

需求

  1. 一键唤起——常用工具一个快捷键,无需输入命令,其他工具在通用终端fish中按需要唤起
  2. 真正的终端——不是 spawn + 抓 stdout,而是 PTY,支持交互式 TUI 程序(lazygit、yazi、vim、htop 等)
  3. 不占用主 UI——浮动 overlay,查看/操作完即关,pi 主界面不受影响
  4. 完整的终端渲染——支持颜色、光标定位、alternate screen(全屏 TUI 程序)、CJK/emoji 宽字符
  5. 快速关闭——Ctrl+Space 强制关闭,exit / Ctrl+D 正常退出
  6. 使用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)

状态栏常驻显示快捷键提示:

1
C-a-f:fish  C-a-y:yazi  C-S-a-g:gcp  C-a-g:lazygit

实现方法

整体架构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
┌─ pi Extension API ──────────────────────────────────┐
│                                                      │
│  pi.registerShortcut("ctrl+alt+f", ...)  ──┐         │
│  pi.registerCommand("/float-fish", ...)  ──┤         │
│                                           │          │
│           ┌───────────────────────────────▼────────┐ │
│           │     ctx.ui.custom({ overlay })          │ │
│           │     FloatTermOverlay                    │ │
│           │                                         │ │
│           │  ┌─────────────────────────────────┐   │ │
│           │  │  node-pty (IPty)                 │   │ │
│           │  │  • ptySpawn(fish/lazygit/yazi)   │   │ │
│           │  │  • onData → TermBuffer.write()   │   │ │
│           │  │  • handleInput → pty.write()     │   │ │
│           │  │  • resize(cols, rows)            │   │ │
│           │  └─────────────────────────────────┘   │ │
│           │                                         │ │
│           │  ┌─────────────────────────────────┐   │ │
│           │  │  TermBuffer (ANSI 终端模拟器)     │   │ │
│           │  │  • CSI 解析(光标、擦除、模式)    │   │ │
│           │  │  • SGR 渲染(颜色、粗体、反视频)  │   │ │
│           │  │  • Alternate Screen(vim/lazygit)│   │ │
│           │  │  • 查询响应(DA/CPR/DSR)         │   │ │
│           │  │  • CJK/emoji 宽字符               │   │ │
│           │  └─────────────────────────────────┘   │ │
│           │                                         │ │
│           │  render() → ANSI-styled terminal lines  │ │
│           │  handleInput() → forward keystrokes     │ │
│           └─────────────────────────────────────────┘ │
│                                                      │
│  ctx.ui.setStatus() → footer shortcut hints          │
└──────────────────────────────────────────────────────┘

关键 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)。区别在于:

spawnnode-pty
输出管道(pipe),无终端特性PTY,程序以为自己在真实终端里
TUI 程序❌ lazygit / vim / htop 无法运行✅ 完全支持
ANSI 转义程序通常不输出程序正常输出颜色、光标控制、alternate screen
resize不支持pty.resize(cols, rows)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import { spawn as ptySpawn } from "node-pty";

this.ptyProcess = ptySpawn(shellCmd, shellArgs, {
  name: "xterm-256color",
  cols,
  rows,
  cwd: process.cwd(),
  env: process.env as Record<string, string>,
});

// 转发用户按键到 PTY
this.ptyProcess.write(data);

// 接收 PTY 输出 → 交给 TermBuffer 解析渲染
this.ptyProcess.onData((data: string) => {
  const responses = this.term.write(data);
  if (responses) this.ptyProcess.write(responses); // 响应终端查询
  this.tui.requestRender();
});

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/lAlternate Screen 切换

SGR 渲染(Select Graphic Rendition)

支持 ANSI 256 色、True Color(RGB→256 映射)、粗体、斜体、下划线、反视频等属性。

反视频(reverse video)的处理很关键:pi 的 overlay 背景是透明的,叠加在终端上。如果直接输出 \x1b[7m(让终端自己做反视频),可能和显式颜色冲突导致闪烁。TermBuffer 的做法是在渲染时手动交换 fg/bg 颜色,而不是输出 \x1b[7m

1
2
3
4
5
6
if (a.reverse) {
  // Swap; supply terminal-appropriate defaults.
  const tmp = fg;
  fg = bg ?? 0;  // default bg → dark
  bg = tmp ?? 7;  // default fg → light
}

Alternate Screen(全屏程序支持)

lazygit、vim、less 等程序会切换终端的 alternate screen buffer\x1b[?1049h),TermBuffer 完整实现了双缓冲切换:

1
2
3
4
5
6
7
8
9
private enterAltScreen(): void {
  this.useAltScreen = true;
  this.altGrid = this.grid;          // 保存主屏幕
  this.altCursorX = this.cursorX;
  this.altCursorY = this.cursorY;
  this.altAttrs = cloneAttrs(this.attrs);
  this.grid = newGrid(this.cols, this.rows);  // 全新空屏
  // ...
}

退出时恢复主屏幕(\x1b[?1049l),内容完整还原。

终端查询响应

有些程序启动时会查询终端能力,如果不响应会导致卡死或显示异常:

查询序列含义响应
CSI cPrimary DA(设备属性)CSI ?1;2c(VT100 + AVO)
CSI > cSecondary DACSI >0;0;0c
CSI 6 nCPR(光标位置报告)CSI row;col R
CSI 5 nDSR(设备状态报告)CSI 0n

CJK / Emoji 宽字符

中日韩字符和 emoji 占 2 个终端列宽。TermBuffer 用 Unicode 码点范围判断宽度,渲染时宽字符占一个 Cell 并标记下一个 Cell 为空占位符:

1
2
// 宽字符第二列为空占位,跳过渲染
if (cell.char === "") continue;

浮动框渲染

浮动框使用 Unicode box-drawing 字符绘制边框(╭╮╰╯│─),紫色(mauve)边框:

  • 内容区域:TermBuffer 渲染的行数组,每行的 ANSI 颜色被保留
  • 底部提示栏:居中显示 Ctrl+Space: close | exit / Ctrl+D: quit shell
  • 尺寸:宽 85%,高 75%,居中锚定
1
2
3
4
5
6
7
{ overlay: true,
  overlayOptions: {
    width: "85%",
    maxHeight: "75%",
    anchor: "center",
  }
}

叠加层支持终端 resize:当 pi 窗口大小变化时,overlay 重新计算内部行列数,同步调用 pty.resize(cols, rows) 通知 PTY 进程。

完整代码

https://github.com/joyanhui/pi-extension/tree/main/pi-ext-float-term

效果

浮动终端界面

1
2
3
4
5
6
7
8
9
╭──────────────────────────────────────────────────╮
│  Welcome to fish, the friendly interactive shell  │
│  /home/y/myws/os-config >                         │
│                                                   │
│                                                   │
│                                                   │
│                                                   │
│  Ctrl+Space: close | exit / Ctrl+D: quit shell    │
╰──────────────────────────────────────────────────╯

状态栏提示

1
C-a-f:fish  C-a-y:yazi  C-S-a-g:gcp  C-a-g:lazygit

操作速查

按键效果
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 插件体系中最复杂的之一,核心挑战在于:

  1. PTY vs Pipe——只有 PTY 才能让交互式 TUI 程序(lazygit、yazi、vim)正常工作,child_process.spawn 的 pipe 模式不行
  2. ANSI 终端模拟器——因为 pi 的 overlay 渲染框架是行数组接口,必须把 PTY 输出重新解析成 Cell 网格再渲染。自己实现一个精简的终端模拟器虽然工作量大,但规避了对 xterm.js 等重型库的依赖
  3. Alternate Screen——全屏 TUI 程序依赖 alternate screen buffer,TermBuffer 的双缓冲机制完整支持了切换和恢复
  4. 反视频渲染——\x1b[7m 在 overlay 叠加场景下有特殊处理,通过手动交换 fg/bg 颜色避免闪烁

结合 bar-cursor 扩展git-changes 扩展,pi 的三个扩展覆盖了光标美化工作区状态终端集成三个维度,全部通过 ~/.pi/agent/extensions/ 目录热加载,配合 NixOS home-manager 管理软链接,配置可追溯、可复现。

TypeScript 扩展的开发体验很好——放到目录就自动加载,/reload 热更新,调试迭代很快。

comments powered by Disqus
Theme Stack