背景
pi 是一个终端 AI 编码助手(coding agent)。它的 TUI 输入框默认使用反视频方块作为光标:把光标所在字符用 \x1b[7m...\x1b[0m(reverse video)高亮显示,即黑底白字变成白底黑字,形如一个方块。
但是我不一直喜欢,我甚至为此去使用 oh my pi 而不是原版pi。然后我发现pi的插件系统 想改这个很简单。
本文记录如何利用 pi 的 CustomEditor 扩展机制,把方块光标改成终端原生竖线光标。
| 项目 | 版本/配置 |
|---|
| pi | 0.80.10 |
| 操作系统 | NixOS 26.05 (Yarara) x86_64 |
| 终端 | ghostty 1.3.1 另:kitty wezterm下也测试通过 |
| Shell | Fish 4.7.1 |
| 终端光标形状支持 | DECSCUSR (\x1b[6 q) |
| bun | 1.3.13 |
| Terminal Font | SF Mono |
思路探索
方案一:插入 Unicode 竖线字符 ▏(失败)
最初的思路:在 render() 中后处理编辑器输出的每一行,找到 CURSOR_MARKER + \x1b[7m{char}\x1b[0m,替换成 ▏(U+258F,LEFT ONE EIGHTH BLOCK)。
1
2
| // ❌ 失败方案:插入竖线字符
return before + CURSOR_MARKER + bar + cursorChar + after;
|
问题:
▏ 是一个真实字符,占 1 格宽度 → 行宽 +1 → padding 不足时越界崩溃- 文本会发生位移——竖线占了一个格子,后面的文字整体右移,和真实编辑器的光标行为完全不同
即使加了 truncateToWidth 兜底截断,文字位移问题也无法解决——终端里做不到"叠加渲染",一个 cell 只能放一个字符。
方案二:利用终端硬件光标形状(DECSCUSR)
现代终端(WezTerm、Kitty、Ghostty、Alacritty、Foot、iTerm2、Windows Terminal)都支持 DECSCUSR(DEC Soft Cursor Shape)转义序列:
| 序列 | 光标形状 |
|---|
\x1b[0 q | 闪烁方块(默认) |
\x1b[2 q | 静态方块 |
\x1b[4 q | 静态下划线 |
\x1b[5 q | 闪烁竖线 |
\x1b[6 q | 静态竖线 |
pi 本身就支持 showHardwareCursor(在 settings.json 中用 \x1b[?25h 显示终端硬件光标),只是形状默认为终端预设(通常是方块)。
核心洞察:硬件光标是终端 GPU 层面渲染的,叠加在字符上方,不占用字符 cell。如果我们:
- 发送
\x1b[6 q 把硬件光标形状设成竖线 - 剥掉 pi 编辑器软件渲染的假方块光标(
\x1b[7m...\x1b[0m) - 保留
CURSOR_MARKER 让 pi 能正确定位硬件光标(IME 候选窗需要)
就能得到一个原生竖线光标,不挤文字,不崩行宽。
补充:pi自带配置项 editorPaddingX
showHardwareCursor + DECSCUSR 工作后,竖线光标在内容贴终端边缘时可能因 margin 裁剪而不可见。pi 原生提供 editorPaddingX(0~3),给输入框左右各留几格空白,问题解决。
完整配置
1. settings.json
在 ~/.pi/agent/settings.json(全局)或 .pi/settings.json(项目级)中添加:
1
2
3
4
| {
"showHardwareCursor": true,
"editorPaddingX": 1
}
|
showHardwareCursor:让终端硬件光标可见(用于竖线渲染 + IME 候选窗定位)editorPaddingX:输入框左右各留 1 格空白,防止竖线贴边被裁
2. 扩展文件
放到 ~/.pi/agent/extensions/bar-cursor.ts,pi 会自动发现并加载,无需在 settings.json 的 extensions 数组中声明。
bar-cursor.ts (70 行)
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
| /**
* Bar Cursor — replaces the default block (reverse-video) cursor with a
* vertical bar using the terminal's native hardware cursor shape.
*
* Auto-discovered from ~/.pi/agent/extensions/.
* Requires showHardwareCursor: true in settings.json (already set).
*
* How it works:
* 1. Sends DECSCUSR `\x1b[6 q` to tell the terminal "use a steady vertical bar".
* 2. Strips the editor's fake block cursor (\x1b[7m…\x1b[0m) so the hardware
* bar cursor shows through unobstructed, without shifting any text.
*
* Width is preserved exactly — no text movement, no crashes.
*/
import { CustomEditor } from "@earendil-works/pi-coding-agent";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { CURSOR_MARKER } from "@earendil-works/pi-tui";
const REVERSE_ON = "\x1b[7m";
const SGR_RESET = "\x1b[0m";
/** Set terminal hardware cursor to a steady vertical bar (DECSCUSR). */
const BAR_CURSOR = "\x1b[6 q";
/** Strip the fake block cursor (reverse-video) so the hardware bar cursor
* is visible. The CURSOR_MARKER is preserved — that's what pi uses to
* position the hardware terminal cursor for IME candidate-window placement.
*
* Width is unchanged: the original reversed char (W cells) is replaced by
* the same char without reverse video, still W cells. No width change = no
* crashes, no text shifting. */
function stripBlockCursor(line: string): string {
const markerIdx = line.indexOf(CURSOR_MARKER);
if (markerIdx === -1) return line;
const afterMarker = line.substring(markerIdx + CURSOR_MARKER.length);
if (!afterMarker.startsWith(REVERSE_ON)) return line;
const contentStart = markerIdx + CURSOR_MARKER.length + REVERSE_ON.length;
const resetIdx = line.indexOf(SGR_RESET, contentStart);
if (resetIdx === -1) return line;
const cursorChar = line.substring(contentStart, resetIdx);
const before = line.substring(0, markerIdx);
const after = line.substring(resetIdx + SGR_RESET.length);
// Just skip the escape codes — character appears normally, width unchanged.
return before + CURSOR_MARKER + cursorChar + after;
}
class BarCursorEditor extends CustomEditor {
render(width: number): string[] {
const lines = super.render(width);
return lines.map(stripBlockCursor);
}
}
// ---------------------------------------------------------------------------
// Extension entry point
// ---------------------------------------------------------------------------
export default function (pi: ExtensionAPI) {
pi.on("session_start", (_event, ctx) => {
// Tell the terminal to use a steady vertical bar for the hardware cursor.
process.stdout.write(BAR_CURSOR);
ctx.ui.setEditorComponent(
(tui, editorTheme, kb) => new BarCursorEditor(tui, editorTheme, kb),
);
});
}
|
效果对比
| 改前 | 改后 |
|---|
| 光标形状 | 反视频方块 | 终端原生竖线 |
| 字符可见性 | 反白(形状模糊) | 正常渲染 |
| 文本位移 | 无 | 无 |
| 行宽变化 | N/A | 不变(不崩溃) |
| IME 候选窗 | 正常 | 正常(CURSOR_MARKER 保留) |
| 行首可见性 | 正常 | editorPaddingX: 1 保证不贴边 |
总结
整个过程的核心教训:
- 终端里做不了叠加——不要试图用字符模拟光标,会破坏布局。交给终端硬件光标。
- pi 扩展很强大——
CustomEditor + setEditorComponent 能完全接管输入框渲染,而且 TypeScript 扩展会被自动发现(放到 ~/.pi/agent/extensions/ 即可)。 - 先理解底层再动手——翻了 pi-tui 源码(
editor.js、tui.js、terminal.js)才搞清楚 CURSOR_MARKER 的定位机制和 DECSCUSR 的协作方式。
扩展部署后,每次启动 pi 自动加载,无需额外配置。配合 NixOS + home-manager 管理 ~/.pi/agent 目录的软链接,整个配置也是声明式、可追溯、可复现的。