Pi TUI 组件库:展示、输入、补全三件套

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 干三件事

CombinedAutocompleteProvidergetSuggestions 按优先级分流:

  1. @路径extractAtPrefix 识别 → 文件模糊补全(带引号前缀、家目录展开、@dir/ 作用域查询)
  2. /命令(光标前无空格)→ 命令列表 fuzzy 过滤
  3. /命令 参数(有空格)→ 找到命令,委托给它的 getArgumentCompletions(参数前缀)

fuzzy 评分算法

顺序子序列匹配,分越低越好

  • 连续匹配:score -= consecutive * 5(奖励连打)
  • 词边界(-_. /: 后):score -= 10
  • 间隙:score += 间隔 * 2(惩罚跳字)
  • 越靠后匹配:+= i * 0.1(轻微惩罚)
  • 完全匹配:-100 直接碾压一切

还藏了个细节:abc123 匹配 123abc 会尝试交换数字和字母段再匹配(score + 5 惩罚交换)——处理文件名排序的实际需求。fuzzyFilter 支持空格/斜杠分多 token,全部要匹配,按总分排序。

竞态三重保险(Editor 侧)

异步补全最怕"旧结果覆盖新输入"。requestAutocomplete 的防抖 + runAutocompleteRequest 用了三层防护:

  1. 防抖@ 附件补全 20ms,其余 0ms(Tab 显式触发 0ms)
  2. AbortSignal:新请求产生时 autocompleteAbort.abort() 取消旧请求
  3. 请求过期校验:每个请求带递增 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.Segmentergrapheme(用户感知字符)切分——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 可注入)。