本文拆解 Pi TUI 的 Markdown、Editor 与 Autocomplete 三大组件,展示其如何用流式渲染优化、纯函数状态管理、可插拔补全和桌面级编辑体验,打造稳定高效的终端交互闭环。
Pi TUI 组件库:展示、输入、补全三件套
Pi Agent Harness 学习记录 · 第五课进阶
前情:第五课已讲 TUI 渲染引擎(差分渲染、纯函数、同步输出、节流与崩溃保护)。本篇深入跑在引擎之上的三个核心组件——它们正好对应 pi 界面的三个诉求:展示回复(Markdown)、输入提示(Editor)、智能补全(Autocomplete)。
一、Markdown 组件:展示层(861 行)
解析不自己写,用 marked
const markdownParser = new Marked();
markdownParser.setOptions({ tokenizer: new StrictStrikethroughTokenizer() });
只自定义了一个 tokenizer——严格删除线 ~~x~~(正则要求 ~~ 后不能紧跟空格/波浪号,避免误伤)。在成熟库上做最小扩展,而不是从零写 parser。
流式适配:trimPartialClosingFences
这是最有意思的一段。注释直接引用了 issue #5825:
Trim streamed partial closing fences so code blocks do not shrink/flicker when the final fence character arrives.
LLM 流式输出时,代码块结束的 是**一个字一个字**到达的。如果直接渲染,"````"→" "→"```" 会让代码块的结尾边框每帧都在变,视觉上闪烁收缩。解法:渲染前检查最后一个 token 是否是不完整的闭围栏(lastLine.length < marker.length),是就把它从文本里裁掉——代码块保持稳定,等完整围栏到了再闭合。这是流式场景独有的工程问题,非流式渲染根本不会遇到。
渲染管线
transform(可换宽度) → 规范化(制表符→3空格) → lexer 解析 → trimPartialClosingFences
→ renderToken 块级 → renderInlineTokens 行内 → wrapTextWithAnsi 换行
→ 左右 padding + 背景填充 → 上下 padding → 缓存
三个缓存字段 cachedText/cachedWidth/cachedLines,text+width 做 key——纯函数 + memo,呼应第五课的确定性:同样输入必然同样输出,所以缓存才安全。
stylePrefix:嵌套样式的"回血"机制(最精巧的设计)
行内渲染里每个样式分支长这样:
case "strong": {
const boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
result += this.theme.bold(boldContent) + stylePrefix; // ← 补回外层样式
break;
}
问题:终端样式用 \x1b[0m(reset)结束。**粗体里套斜体** 渲染时,斜体的 reset 会把粗体也抹掉,后面就变回默认样式。解法是每用完一个行内样式,就重新输出一遍"默认样式前缀"(stylePrefix),让后续文本恢复外层样式。
探测前缀的方式很聪明:用 sentinel 字符 \u0000 走一遍样式函数,从结果里定位 sentinel 的位置,它前面的那串 ANSI 就是前缀:
const sentinel = "\u0000";
const styled = styleFn(sentinel); // 例如 "\x1b[1m\x1b[38;5;12m\u0000"
const sentinelIndex = styled.indexOf(sentinel);
return styled.slice(0, sentinelIndex); // "\x1b[1m\x1b[38;5;12m" 就是前缀
渲染结束后还有个收尾:
while (stylePrefix && result.endsWith(stylePrefix)) {
result = result.slice(0, -stylePrefix.length); // 剥掉末尾多余的残留前缀
}
这样文本结尾不会有"挂着没用"的样式代码。
终端能力探测:OSC 8 超链接
if (getCapabilities().hyperlinks) {
result += hyperlink(styledLink, token.href); // OSC 8:真·可点击链接,URL 不打印
} else {
// 回退:文本≠URL 时打印 "(URL)"
}
同一个 markdown 在不同终端上渲染策略不同——能力探测决定走哪条路,而不是一刀切。
二、Autocomplete:补全层(786 行)
Provider 接口:把补全做成可插拔
interface AutocompleteProvider {
triggerCharacters?: string[]; // 自然触发字符
getSuggestions(lines, cursorLine, cursorCol, {signal, force}): Promise<...>; // 异步、可取消
applyCompletion(lines, cursorLine, cursorCol, item, prefix): {lines, cursorLine, cursorCol}; // 纯函数!返回新文本+光标
}
注意 applyCompletion 不修改编辑器,而是返回新状态——纯函数,调用方(Editor)自己决定怎么应用。和第五课"纯计算/副作用分离"一脉相承。
一个 provider 干三件事
CombinedAutocompleteProvider 的 getSuggestions 按优先级分流:
@路径→extractAtPrefix识别 → 文件模糊补全(带引号前缀、家目录展开、@dir/作用域查询)/命令(光标前无空格)→ 命令列表 fuzzy 过滤/命令 参数(有空格)→ 找到命令,委托给它的getArgumentCompletions(参数前缀)
fuzzy 评分算法
顺序子序列匹配,分越低越好:
- 连续匹配:
score -= consecutive * 5(奖励连打) - 词边界(
-_. /:后):score -= 10 - 间隙:
score += 间隔 * 2(惩罚跳字) - 越靠后匹配:
+= i * 0.1(轻微惩罚) - 完全匹配:
-100直接碾压一切
还藏了个细节:abc123 匹配 123abc 会尝试交换数字和字母段再匹配(score + 5 惩罚交换)——处理文件名排序的实际需求。fuzzyFilter 支持空格/斜杠分多 token,全部要匹配,按总分排序。
竞态三重保险(Editor 侧)
异步补全最怕"旧结果覆盖新输入"。requestAutocomplete 的防抖 + runAutocompleteRequest 用了三层防护:
- 防抖:
@附件补全 20ms,其余 0ms(Tab 显式触发 0ms) - AbortSignal:新请求产生时
autocompleteAbort.abort()取消旧请求 - 请求过期校验:每个请求带递增
requestId+ 发起时的文本/光标快照,返回后校验:
private isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol) { ... }
三层任何一层拦住,结果就丢弃。网络慢的机器上这决定了体验好坏——否则你打完字,半秒前的结果弹出来覆盖掉。
三、Editor:交互层(2351 行)
状态:一个纯结构体
interface EditorState { lines: string[]; cursorLine: number; cursorCol: number; }
所有编辑操作都是"读取 state → 产出新 state",配合 onChange 回调通知外部。
输入分发:一个入口,全部走 keybindings
handleInput(data) 是唯一入口,每个操作都是 kb.matches(data, "tui.editor.xxx")——键位可配置,不是硬编码。处理顺序:jump 模式 → bracketed paste → undo → autocomplete 模式 → 删除 → kill-ring → 光标移动 → 换行 → 提交 → 方向键+历史。优先级本身就是一种设计:模态状态(jump/autocomplete)最先截获输入。
Bracketed Paste + 粘贴标记
if (data.includes("\x1b[200~")) { this.isInPaste = true; this.pasteBuffer = ""; }
终端用 \x1b[200~...\x1b[201~ 包裹真实粘贴(区别于手打),Editor 缓冲整段后统一插入。大粘贴用占位符:超过阈值时只插入 [paste #N (+N lines)] 标记,文本进注册表 pastes: Map<number, string>——为什么?几千行文本直接进 state,每次输入都会触发全量重渲染,用标记占位则编辑器轻如羽毛,展示时再展开(expandPasteMarkers)。光标移动时标记被当原子段(isAtomicSegment),不可断入。
Undo:全量快照栈(不是逆操作)
// undo-stack.ts —— 极简到 20 行
push(state) { this.stack.push(structuredClone(state)); } // clone-on-push 深克隆
每个可撤销操作前 push 整个 EditorState 的深克隆,undo 就是 pop + Object.assign 回去。不是命令模式、不存逆操作——因为文本编辑的逆操作难写对(合并、边界、多行),而快照语义无懈可击。代价是内存,但 EditorState 只是文本数组+光标,代价可忽略。
fish 式合并(undo 单元粒度,insertCharacter 里):
if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
this.pushUndoSnapshot(); // 空格或非"打字中"状态 → 存快照
}
this.lastAction = "type-word"; // 连续词字符不再存
效果:打一整个单词只算一次 undo;空格会存自己之前的状态,所以 undo 能把"空格+后面的词"一起干掉。粘贴等原子操作 skipUndoCoalescing 跳过合并。
Kill-ring:Emacs 血统
连续 kill(删除)合并成一个条目(accumulate + prepend/append 区分方向),yank 取最近,yank-pop 轮转更老的——经典 Emacs 三件套,pi 全搬来了。
Word Wrap:grapheme 级别的换行
wordWrapLine 是换行布局核心,几个细节:
- 用
Intl.Segmenter按 grapheme(用户感知字符)切分——emoji、组合字符、宽字符不会裂开 - 记录"wrap 机会点"(空白后第一个非空白处),超宽时回溯到最近机会点,而不是硬切
- CJK 特判:
cjkBreakRegex允许任意相邻中文字符之间断行(英文不行) - 比 maxWidth 还宽的原子段(如窄终端里的 paste marker)递归按 grapheme 再切,但逻辑上仍是原子——"切是视觉行为,原子是逻辑行为",两者分离
其他 Emacs 细节
jumpToChar:f/F 式字符跳跃(多行搜索,跳过当前位置)preferredVisualCol:粘性列——上下移动时保持视觉列,行短了也不丢(Emacs 的goal-column)- 历史浏览:up/down 进历史 +
historyDraft保留未提交的草稿(bash 也这样)
总结
三个组件合起来,就是 pi 输入/输出体验的完整闭环:
- Markdown 解决"怎么把 LLM 的 markdown 流式、稳定、可主题化地画到终端"
- Editor 解决"怎么让终端里的多行输入拥有桌面编辑器体验"(undo、kill-ring、wrap、历史)
- Autocomplete 解决"怎么把文件系统、命令、参数智能地喂给用户"
贯穿始终的三个设计母题(与前几课呼应):纯计算/副作用分离(applyCompletion 返回新状态、render 纯函数)、场景化简化(粘贴标记、CJK 断行、流式围栏裁剪都是场景驱动的取舍)、一份定义多处使用(keybindings 可配置、Provider 可插拔、Theme 可注入)。