小类随手记

pi agent输入框光标美化:从方块到竖线

通过 pi 的 CustomEditor 扩展机制,将输入框默认的方块(反视频)光标替换为终端原生竖线光标,兼容 IME 中文输入。

背景

pi 是一个终端 AI 编码助手(coding agent)。它的 TUI 输入框默认使用反视频方块作为光标:把光标所在字符用 \x1b[7m...\x1b[0m(reverse video)高亮显示,即黑底白字变成白底黑字,形如一个方块。

但是我不一直喜欢,我甚至为此去使用 oh my pi 而不是原版pi。然后我发现pi的插件系统 想改这个很简单。

本文记录如何利用 pi 的 CustomEditor 扩展机制,把方块光标改成终端原生竖线光标

项目版本/配置
pi0.80.10
操作系统NixOS 26.05 (Yarara) x86_64
终端ghostty 1.3.1 另:kitty wezterm下也测试通过
ShellFish 4.7.1
终端光标形状支持DECSCUSR (\x1b[6 q)
bun1.3.13
Terminal FontSF 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 格宽度 → 行宽 +1 → padding 不足时越界崩溃
  2. 文本会发生位移——竖线占了一个格子,后面的文字整体右移,和真实编辑器的光标行为完全不同

即使加了 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。如果我们:

  1. 发送 \x1b[6 q 把硬件光标形状设成竖线
  2. 剥掉 pi 编辑器软件渲染的假方块光标(\x1b[7m...\x1b[0m
  3. 保留 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 保证不贴边

总结

整个过程的核心教训:

  1. 终端里做不了叠加——不要试图用字符模拟光标,会破坏布局。交给终端硬件光标。
  2. pi 扩展很强大——CustomEditor + setEditorComponent 能完全接管输入框渲染,而且 TypeScript 扩展会被自动发现(放到 ~/.pi/agent/extensions/ 即可)。
  3. 先理解底层再动手——翻了 pi-tui 源码(editor.jstui.jsterminal.js)才搞清楚 CURSOR_MARKER 的定位机制和 DECSCUSR 的协作方式。

扩展部署后,每次启动 pi 自动加载,无需额外配置。配合 NixOS + home-manager 管理 ~/.pi/agent 目录的软链接,整个配置也是声明式、可追溯、可复现的。

comments powered by Disqus
Theme Stack