兰 亭 墨 苑
期货 · 量化 · AI · 终身学习
首页
归档
编辑文章
标题 *
URL 别名 *
内容 *
(支持 Markdown 格式)
# Pi TUI 组件库:展示、输入、补全三件套 > Pi Agent Harness 学习记录 · 第五课进阶 > 前情:第五课已讲 TUI 渲染引擎(差分渲染、纯函数、同步输出、节流与崩溃保护)。本篇深入跑在引擎之上的三个核心组件——它们正好对应 pi 界面的三个诉求:**展示回复**(Markdown)、**输入提示**(Editor)、**智能补全**(Autocomplete)。 ## 一、Markdown 组件:展示层(861 行) ### 解析不自己写,用 marked ```ts 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:嵌套样式的"回血"机制(最精巧的设计) 行内渲染里每个样式分支长这样: ```ts 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 就是前缀**: ```ts 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" 就是前缀 ``` 渲染结束后还有个收尾: ```ts while (stylePrefix && result.endsWith(stylePrefix)) { result = result.slice(0, -stylePrefix.length); // 剥掉末尾多余的残留前缀 } ``` 这样文本结尾不会有"挂着没用"的样式代码。 ### 终端能力探测:OSC 8 超链接 ```ts if (getCapabilities().hyperlinks) { result += hyperlink(styledLink, token.href); // OSC 8:真·可点击链接,URL 不打印 } else { // 回退:文本≠URL 时打印 "(URL)" } ``` **同一个 markdown 在不同终端上渲染策略不同**——能力探测决定走哪条路,而不是一刀切。 ## 二、Autocomplete:补全层(786 行) ### Provider 接口:把补全做成可插拔 ```ts 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` 按优先级分流: 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` + 发起时的文本/光标快照,返回后校验: ```ts private isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol) { ... } ``` 三层任何一层拦住,结果就丢弃。**网络慢的机器上这决定了体验好坏**——否则你打完字,半秒前的结果弹出来覆盖掉。 ## 三、Editor:交互层(2351 行) ### 状态:一个纯结构体 ```ts 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 + 粘贴标记 ```ts 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:全量快照栈(不是逆操作) ```ts // undo-stack.ts —— 极简到 20 行 push(state) { this.stack.push(structuredClone(state)); } // clone-on-push 深克隆 ``` **每个可撤销操作前 push 整个 EditorState 的深克隆**,undo 就是 pop + `Object.assign` 回去。不是命令模式、不存逆操作——**因为文本编辑的逆操作难写对(合并、边界、多行),而快照语义无懈可击**。代价是内存,但 EditorState 只是文本数组+光标,代价可忽略。 **fish 式合并**(undo 单元粒度,`insertCharacter` 里): ```ts 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 可注入)。
配图 (可多选)
选择新图片文件或拖拽到此处
标签
更新文章
删除文章