Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 25 章:编辑器组件 — 交互复杂度的集中地

定位:本章解析 pi 的编辑器组件为什么代码量超过很多独立项目。 前置依赖:第 24 章(pi-tui 框架)。 适用场景:当你想理解终端交互的真实复杂度。

为什么编辑器这么复杂?

pi 的 Editor 组件处理的不只是“输入文字“:

  • 多行编辑Shift+EnterCtrl+J 换行(默认键位见 keybindings.ts:118,具体哪个可用取决于终端能力)
  • @ 文件引用:输入 @ 触发模糊文件搜索,选中后附加为上下文
  • !command 执行:输入 ! 前缀运行 bash,输出送入 LLM
  • Tab 路径补全:自动补全文件路径和斜杠命令
  • 图片粘贴Ctrl+V 检测剪贴板中的图片
  • 滚动与光标:长输入的垂直滚动和光标管理

每个功能单独看都不复杂。但组合在一起 — 用户在多行编辑中间触发了 @ 搜索,搜索结果弹出覆盖层,用户按 Tab 选中文件,覆盖层关闭,光标回到编辑位置 — 这些状态转换的组合爆炸是编辑器代码量大的根本原因。

Editor 类结构

// packages/tui/src/components/editor.ts:217
export class Editor implements Component, Focusable {
  // ... 核心编辑状态 ...
  private autocompleteProvider?: AutocompleteProvider;
  private autocompleteList?: SelectList;
  private autocompleteState: "regular" | "force" | null = null;
  private autocompletePrefix: string = "";
  private autocompleteMaxVisible: number = 5;
  private autocompleteAbort?: AbortController;
  private autocompleteDebounceTimer?: ReturnType<typeof setTimeout>;
  private autocompleteRequestTask: Promise<void> =
    Promise.resolve();
  private autocompleteStartToken: number = 0;
  private autocompleteRequestId: number = 0;

  // Paste tracking for large pastes
  private pastes: Map<number, string> = new Map();
  private pasteCounter: number = 0;
  private pasteBuffer: string = "";
  // ...
}

光是自动补全相关的字段就有 10 个。这不是过度设计 — 每个字段都解决一个真实的并发问题:

  • autocompleteAbort:取消正在进行的补全请求(用户继续输入时需要取消旧请求)
  • autocompleteDebounceTimer:防抖,用户快速输入时不触发每一个字符的补全
  • autocompleteRequestId:请求序号,确保过时的异步响应不会覆盖新的结果
  • autocompleteStartToken:记录补全开始时的光标位置,用于在完成补全时正确替换文本

自动补全系统

Editor 的自动补全通过 AutocompleteProvider 接口和外部系统对接:

// packages/tui/src/autocomplete.ts:243-269
export interface AutocompleteProvider {
  /** 在 token 边界自然触发本 provider 的字符。 */
  triggerCharacters?: string[];

  getSuggestions(
    lines: string[],
    cursorLine: number,
    cursorCol: number,
    options: { signal: AbortSignal; force?: boolean },
  ): Promise<AutocompleteSuggestions | null>;

  applyCompletion(/* ...替换文本并返回新光标... */): {
    lines: string[];
    cursorLine: number;
    cursorCol: number;
  };

  /** 显式 Tab 补全时是否应触发文件补全。 */
  shouldTriggerFileCompletion?(
    lines: string[], cursorLine: number, cursorCol: number
  ): boolean;
}

这个接口比早期版本扩展了不少,每个新增字段都对应一个真实的交互需求:

  • getSuggestions 的入参从早期的 (text, cursorPosition, signal) 改成了 (lines, cursorLine, cursorCol, options) —— 多行编辑下“光标位置“必须用行号+列号表达,单一偏移量不够;options.force 取代了外部的模式标志。
  • triggerCharacters(v0.79.1,#4703)声明哪些字符在 token 边界自然触发本 provider,比如 #$。Editor 据此决定何时不等 Tab 就主动唤起补全。
  • shouldTriggerFileCompletion(v0.69.0)让 provider 自己判断“此刻按 Tab 是否该做文件路径补全“,把这个上下文敏感的决策下放给 provider,而不是写死在 Editor 里。

与之配套,SlashCommand 接口也新增了 argumentHint?(v0.67.6,autocomplete.ts:230)—— 斜杠命令可以声明参数提示(如 /model <name>),补全列表渲染时显示出来。

补全触发有两种模式:

  • "regular":用户输入时自动触发(带 debounce),比如输入 / 后提示斜杠命令
  • "force":用户按 Tab 明确请求补全

补全请求是异步的 — 文件路径补全可能需要扫描文件系统,斜杠命令补全可能需要查询已注册的命令列表。异步意味着竞态条件:用户发起补全请求后继续输入,旧请求的结果到达时上下文已经变化。

autocompleteRequestId 解决这个问题:每次发起请求时递增 ID,响应到达时检查 ID 是否匹配当前请求。不匹配的响应被静默丢弃。这比 debounce 更精确 — debounce 只能延迟请求,但不能处理“请求已发出、响应延迟到达“的情况。

补全列表使用 SelectList 组件渲染,通过 overlay 系统(第 24 章)定位在光标下方。最多显示 5 项(autocompleteMaxVisible),可以通过配置调整:

// packages/tui/src/components/editor.ts:287-288
const maxVisible = options.autocompleteMaxVisible ?? 5;
this.autocompleteMaxVisible = Number.isFinite(maxVisible)
  ? Math.max(3, Math.min(20, Math.floor(maxVisible)))
  : 5;

@ 文件引用

输入 @ 触发文件搜索覆盖层。这和普通的自动补全不同 — 它弹出一个独立的搜索界面,支持模糊匹配,选中后在消息中插入文件引用标记。

工作流程:

  1. 用户输入 @,Editor 检测到这是文件引用触发字符
  2. 弹出文件搜索 overlay(使用 find 工具的后端逻辑扫描项目文件)
  3. 用户继续输入缩小搜索范围,搜索结果实时更新
  4. 用户按 Enter 选中文件,overlay 关闭
  5. 文件路径作为上下文附加到消息中(不是插入文本 — 而是作为 context attachment)

这个流程的关键设计是:文件引用不是文本替换,而是结构化数据。LLM 收到的不是 @src/foo.ts 这样的字符串,而是一个包含文件路径的 context 对象。这让后端可以把文件内容读出来作为附加上下文发送给 LLM。

! Bash 执行

! 开头的消息触发 bash 执行模式:

  • !npm test — 执行命令,输出作为用户消息的一部分发送给 LLM
  • !! — 重复上一条 bash 命令

这个功能把编辑器变成了一个混合输入界面 — 既是聊天输入框,也是命令行。用户不需要切换到单独的终端窗口就能运行命令并把结果分享给 LLM。

粘贴处理

粘贴在终端中比在浏览器中复杂得多。Editor 需要处理多种场景:

Bracketed Paste Mode

现代终端支持 bracketed paste — 在粘贴内容前后加入标记序列(\x1b[200~\x1b[201~),让应用区分“用户输入“和“粘贴内容“。Editor 检测到 bracketed paste 开始标记后,把后续输入缓存到 pasteBuffer 中,直到结束标记。

// packages/tui/src/components/editor.ts:252-257
// Paste tracking for large pastes
private pastes: Map<number, string> = new Map();
private pasteCounter: number = 0;

// Bracketed paste mode buffering
private pasteBuffer: string = "";

大文本粘贴折叠

用户可能粘贴几百行的日志或代码。如果直接显示在编辑器中,会让输入区域变得巨大。Editor 的解决方案是 paste marker

// packages/tui/src/components/editor.ts:12-16
const PASTE_MARKER_REGEX =
  /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
const PASTE_MARKER_SINGLE =
  /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;

大粘贴被替换为一个标记如 [paste #1 +123 lines],原始内容保存在 pastes Map 中。这个标记在编辑器中作为一个原子单元处理 — 光标移动跳过它,删除时整体删除。

segmentWithMarkers 函数包装了 Intl.Segmenter,让 paste marker 在 Unicode 分词层面也表现为单个 segment:

// packages/tui/src/components/editor.ts:30-45
function segmentWithMarkers(
  text: string, validIds: Set<number>
): Iterable<Intl.SegmentData> {
  if (validIds.size === 0 || !text.includes("[paste #")) {
    return baseSegmenter.segment(text);
  }
  // Find all marker spans with valid IDs
  // Merge graphemes within markers into single segments
  // ...
}

这个实现的精巧之处:paste marker 的存在对 word wrap、光标移动、删除操作都是透明的 — 因为它们在分词层就被处理成了原子单元。

paste registry 不是一个“设了就不管“的表 —— 它必须和文本严格对账。删除一个 paste marker(比如退格退掉它)时,Editor 不只是从文本里抹掉标记,还会把 pastes Map 里对应条目删掉,并把更大 id 的条目按升序整体下移一位,让 [paste #3][paste #1] 被删后变成 [paste #2]editor.ts:1296-1306,v0.80.4 #6397 修复了删除/清屏后残留陈旧记账的问题);提交或清屏后整个 registry 连同计数器一起清零(editor.ts:1265-1266)。这套记账还有一条不易察觉的支线 —— 它和 undo 是绑定的,见下面的 Undo Stack。

IME 支持

IME(Input Method Editor)是中日韩文输入的基础设施。终端中的 IME 支持比 GUI 应用困难得多:

  1. 光标定位。IME 需要硬件光标来定位候选窗口:它读硬件光标位置弹出拼音/候选框,而用户在编辑器里看到的可见光标其实是 render() 反色画出的软件块。这套双层光标的完整机制(软件反色块 + 硬件光标叠加、退出清理)见第 24 章「双层光标模型」,这里只强调 IME 依赖的是硬件光标那一层(#6790)。

  2. compose 事件。IME 的输入过程是多步的 — 用户按下拼音字母,IME 在 compose 状态下显示拼音,用户选择候选字后 IME 发送最终字符。Editor 需要正确处理这个 compose 过程,不能把中间状态当作最终输入。

  3. Kitty keyboard protocol。Kitty 终端的增强键盘协议可以区分 keydown 和 keyup,提供更精确的键盘事件。但 IME 在这个协议下的行为和标准模式不同,需要特殊处理。

Word Wrap 算法

Editor 的 word wrap 不是简单的“按宽度截断“。它需要考虑:

  • Unicode 字符宽度:CJK 字符宽 2 列,拉丁字符宽 1 列,emoji 可能宽 2 列
  • 换行点选择:优先在空格处换行,其次在标点处换行,最后才强制在 grapheme 边界换行
  • Paste marker:作为原子单元不能被换行拆开(除非宽度超过整行)

这里的“字符边界“在 v0.79.x 被收紧为 grapheme(字素簇)边界#5495 等)。原因是:强制换行如果落在一个多码位 grapheme(如带变音符的字母、emoji ZWJ 序列)的中间,会把一个视觉字符劈成两半,渲染错乱。Editor 用 getGraphemeSegmenter()(包装 Intl.Segmentereditor.ts:9,18)先把行切成 grapheme,换行只在 grapheme 之间发生。这一改动同时修复了旧版本 CJK 文本换行时右侧出现的大段空隙问题。Unicode 词边界的导航与删除(Ctrl+←/→、按词删除)则由 word-navigation.tsfindWordBackward/findWordForward 基于 Intl.Segmenter 的 word 分词实现。

// packages/tui/src/components/editor.ts:101-108
export function wordWrapLine(
  line: string,
  maxWidth: number,
  preSegmented?: Intl.SegmentData[]
): TextChunk[] {
  if (!line || maxWidth <= 0) {
    return [{ text: "", startIndex: 0, endIndex: 0 }];
  }
  const lineWidth = visibleWidth(line);
  if (lineWidth <= maxWidth) {
    return [{ text: line, startIndex: 0, endIndex: line.length }];
  }
  // ...complex wrapping logic...
}

TextChunk 不只是文本片段 — 它还记录了在原始行中的起始和结束位置(startIndexendIndex)。这让光标在 wrapped 行之间移动时可以正确映射回原始文本位置。

滚动指示器的宽度安全

输入内容超过可视高度时,Editor 在被裁掉的方向画一条滚动指示器边框,形如 ─── ↑ 12 more ─────createScrollBordereditor.ts:259-267)。正常情况下它把提示文字左对齐、右侧用 补满整行宽度。

问题出在窄终端:当终端宽度比提示文字本身还窄时,“补满剩余宽度“的 remaining 会变成负数,早期版本据此 "─".repeat(负数) 会抛异常、把整个渲染带崩(v0.82.0 #7015 修复)。现在的处理是宽度安全的 —— remaining < 0 时改走截断分支:用 sliceByColumn列宽(而不是字符数)把指示器截到放得下的宽度,再接一个 ... 省略号(editor.ts:265-267)。按列宽截断这点很重要,因为指示器里可能混有宽度为 2 的字符,按字符数截断仍会溢出。

Kill Ring

Editor 实现了 Emacs 风格的 kill ring — Ctrl+K 杀掉光标到行尾的内容,Ctrl+Y 粘贴最近 kill 的内容。这不是剪贴板 — 它是一个独立的循环缓冲区,保存了多次 kill 的历史。

为什么要实现 kill ring 而不只是用系统剪贴板?因为在终端中访问系统剪贴板需要 OSC 52 序列支持(不是所有终端都支持),而且 kill ring 和 Emacs 快捷键是终端用户的肌肉记忆。

Undo Stack

编辑器维护了自己的 undo 栈(UndoStack),支持 Ctrl+Z 撤销和 Ctrl+Shift+Z / Ctrl+Y 重做。这在终端编辑器中不是标配 — 大多数终端输入框不支持撤销。但对于多行编辑场景(用户可能编辑一段代码片段作为 prompt),撤销功能从“可选“变成了“必要“。

关键的一点是:undo 快照不只存文本。EditorSnapshot 里同时装了编辑状态、pastes registry 和 pasteCountereditor.ts:215-220)—— 每次改动前 pushUndoSnapshot 把三者一起压栈,undo() 弹栈时把文本和 paste registry 一并还原(editor.ts:2012-2028,v0.81.0 #6844)。这正是上一节粘贴折叠留下的那条支线:如果 undo 只回滚文本、不回滚 registry,一次“撤销删除 paste marker“就会让标记回到文本里、但它指向的原始内容已经从 registry 里消失,提交时 [paste #1] 会展开成空。把 paste registry 纳入 undo 快照,才让“粘贴折叠“和“撤销“这两个看似独立的机制真正自洽。

状态转换的复杂性

编辑器在任意时刻可能处于以下状态之一:

  1. 普通编辑:正常文本输入
  2. 自动补全:补全列表可见,上下箭头选择,Enter 确认,Esc 取消
  3. 文件搜索@ 触发的搜索 overlay
  4. Compose:IME 正在组合输入

这些状态之间的转换不是线性的。用户可以在 compose 状态下按 @,需要先完成 compose 再进入文件搜索;在自动补全时按 Esc,需要关闭补全但不退出编辑。每种转换都需要正确处理焦点、光标位置、缓冲区内容。

这就是为什么 Editor 是 pi-tui 中代码量最大的组件 — 不是因为单个功能复杂,而是因为功能组合的状态空间是乘法增长的。

取舍分析

得到了什么

流畅的交互体验。用户不需要离开编辑器就能引用文件、执行命令、查看补全。这些快捷方式让 pi 感觉像一个 IDE 而不是一个聊天框。

大粘贴的优雅处理。Paste marker 机制让用户可以粘贴大段内容而不破坏编辑器的可用性。

放弃了什么

大量的边界条件。每种输入方式(键盘、粘贴、IME、Kitty protocol)和每种交互模式(普通编辑、搜索覆盖层、补全菜单)的组合都需要测试。

维护负担集中。Editor 几乎每个版本都有交互改进或 bug 修复,它是 pi-tui 中变化最频繁的代码。


版本演化说明

本章核心分析基于 pi-mono v0.66.0,已对照 v0.82.1 核实。Editor 组件是 pi-tui 中变化最频繁的组件 — 几乎每个版本都有交互改进或 bug 修复。Paste marker 机制是后来添加的, 早期版本会直接将大粘贴内容全部显示在编辑器中。

v0.66 → v0.79 的主要变化:AutocompleteProvider 接口扩展 —— getSuggestions 改为多行签名 (lines, cursorLine, cursorCol, options), 新增 triggerCharacters(v0.79.1,#4703)与 shouldTriggerFileCompletion(v0.69.0),SlashCommand 新增 argumentHint(v0.67.6); word wrap 收紧为按 grapheme 边界断行(v0.79.x,#5495),修复 CJK 换行大尾隙;新增基于 Intl.Segmenter 的 Unicode 词边界导航/删除(word-navigation.ts)。

v0.79 → v0.82 的补充:换行默认键位补上 Ctrl+J(与 Shift+Enter 并列,keybindings.ts:118);undo 快照纳入 paste registry,撤销可同时还原文本与粘贴表(EditorSnapshot,v0.81.0 #6844),并补齐 paste marker 删除/清屏后的记账清理(v0.80.4 #6397);窄终端滚动指示器按列宽截断、不再崩溃(v0.82.0 #7015);并把 IME 光标定位对齐到第 24 章的双层光标模型(#6790)。