来自用户的建议和方案
────────────────────────────
TUI 卡顿问题三策分析法
诊断:根本瓶颈在哪里?
通过源码审计,我定位到几个关键热路径上的性能问题:
│ 瓶颈 │ 位置 │ 每次触发的频率
───┼───────────────────────┼─────────────────────────────────────────────────────────┼────────────────────────────
1 │ 全量消息重渲染 │ refreshViewportContentForSize() → chat.SetMessages() → │ 每次 Event*Delta 都触发
│ │ renderChatItemLines() │
2 │ Markdown 重复解析 │ ChatLines() → renderCard() → Markdown() → goldmark │ 同上,所有消息都重新
│ │ │ goldmark parse
3 │ chatTail 循环内渲染 │ chatTailMessagesForView() 的 while 循环每轮都调 │ 每轮 while 都全量重渲染
│ │ ChatLines() │
4 │ Update + View │ Update() 调 refreshViewportContent(),View() 又调 │ 每个 frame
│ 双份渲染 │ ensureViewportContentForSize() │
5 │ Composer.View() │ wrappedLineCount() + wrapComposerLine() + │ 每次 View() 调用
│ 行计算 │ promptLineAt() │
────────────────────────────
下策(最保守,最拘束)— 局部缓存 + 频控
思路:不动架构,仅在现有代码上做缓存和节流优化。
具体措施:
- 标记化渲染缓存(Message-level cache):在 chatList 中缓存 chatItem.lines 的结果。SetMessages()
时只对新/变化的消息重新调用 renderChatItemLines(),已有消息直接从缓存拿。实现一个 generation 对比,只对 generation
差异的消息重渲染。
- View 层跳过多余的重渲染:在 ensureViewportContentForSize() 中当 viewportLayoutReady && viewportLayoutWidth ==
mainWidth && viewportLayoutHeight == bodyHeight 时,不在 View() 中重复 SetMessages,而是直接复用已经设置好的
chatList 的 items。
- 限制 markdown 输入预处理:在 normalizeMarkdownLinks() 中对大输入(> 256KB)直接跳过 goldmark,走纯文本路径。
- chatTailMessagesForView 二分查找优化:将 while 线性裁剪改为二分查找 + ChatLines()
预计算行数,而不是每次都重渲染。
- Delta 渲染节流:对 EventAssistantDelta 等流式事件,在 handleServiceEvents 中累积 delta 的渲染,设置一个
renderDeltaFrame(如 50ms),在 frame 内只更新 assembler 不触发全量重渲染,攒一批才刷到 chatList。
优点:
• 改动小,风险低,可以逐步落地
• 对现有代码侵入性小
缺点:
• 治标不治本,核心架构瓶颈未解决
• markdown 仍然是每次重新解析
• 当 transcript 积累到几百条消息时,即使有缓存,初次 SetMessages 仍会很慢
• View() 的 chat.View() 仍需遍历所有 items
────────────────────────────
中策(较为可行)— 增量渲染引擎 + 流式 Markdown 渲染
思路:重构渲染层,从"全量渲染"变为"仅渲染 delta 变化";引入流式 markdown 渲染避免每次重新解析全文。
具体措施:
- 增量 chatList:
• 不再每次 SetMessages() 重建所有 chatItem,而是维护一个 items 缓冲队列
• 新增 AppendMessage(msg) / UpdateLastMessage(msg) / RemoveMessage(id) 方法,只对变化的消息做
renderChatItemLines()
• chatList.View() 保持遍历现有 items 的逻辑不变
- 流式 Markdown 渲染器:
• 当前每次 delta 都 goldmark 全量解析。改为将 goldmark AST 缓存到 chatItem 中,新 delta 到来时,只对 AST 做增量
append 文本,重新从 AST 渲染到 ANSI
• 或者更实际的做法:利用 goldmark 的 parser.Context 和 Segment API,维护每个消息的 parser state
和已经渲染好的行缓存,增量追加
- 渲染与事件处理分离:
• 将 refreshViewportContentForSize() 从同步调用改为 tea.Cmd 异步触发,避免在 Update() 中阻塞事件处理
• 引入一个 renderTickCmd 以固定帧率(如 30fps)触发渲染,delta 事件只更新数据模型不直接触发票面渲染
- Viewport content 双缓冲:
• View() 中不直接调用 chat.View(),而是从预渲染好的 viewportContent 缓存中取
• 只有 renderTickCmd 触发时才刷新缓存
- 优化 chatTailMessagesForView:
• 预计算每个 message 的行数(在 chatItem 中缓存 lineCount)
• 利用前缀和查找需要显示的消息,避免 while 循环中反复调昂贵的 ChatLines()
优点:
• 从 O(n) 降到 O(Δ + visible),n 是 transcript 总条数
• 大 transcript 场景(数百条消息)仍能保持流畅
• 架构上更合理,便于后续优化
缺点:
• 改动范围大,涉及 chatList、chatViewport、assembler、渲染管线
• 流式 goldmark AST 增量没有现成 API,需要自己包装(或者换轻量级 markdown 库)
• 异步渲染容易引入闪烁或时序问题
────────────────────────────
上策(最长远,收益最大,风险最高)— 重写 TUI 渲染架构,基于 Rust/Go 原生 2D 渲染
思路:承认 Bubble Tea + lipgloss + goldmark 在超大规模 Terminal UI 场景下的性能天花板,从底层重构。
具体措施:
- 替换核心渲染引擎:放弃 lipgloss 的行级样式组合,采用基于双缓冲字符网格的直接终端渲染。
• 维护一个 [row][col]cell 的二维网格,每个 cell 包含 rune + ANSI 样式
• View() 输出时只 diff 新旧网格,输出最小 ANSI diff
• 这类似 Tmux / Neovim 的渲染策略
- 自研轻量级 Markdown 渲染:放弃 goldmark(完整 CommonMark 解析器),改为:
• 基于有限状态机的增量 markdown 流式解析器,专门为 TUI 聊天场景优化
• 支持代码块、行内样式、链接这几个核心特性即可
• 解析输出直接写入字符网格,跳过中间 ANSI string 经过 lipgloss 再组合的阶段
- Renderer 线程独立:将渲染推到独立 goroutine(通过 tea.Cmd 异步化),主 event loop 只做数据模型更新
• 利用 Bubble Tea 的 tea.Batch 组合 waitEventCmd 与定时的 renderFrameCmd
• 渲染 goroutine 输出最终网格快照,View() 直接从快照读
- 虚拟化列表(Virtualized List):
• chatList 不再持有所有消息的渲染行缓存,而是只持有可见窗口上下 N 条消息的渲染结果
• 滚动时动态加载 + 回收
• 借鉴 React Virtualized / Android RecyclerView 的 ViewHolder 模式
- 专用 terminal 渲染库:考虑使用 tcell + gomitchelli 风格的双缓冲方案替代 Bubble Tea 的 line-buffered 输出
优点:
• 性能上限极高:渲染 O(visible_area) 而非 O(total_content)
• 超大 transcript(几千条)依然流畅
• 不再受 lipgloss / goldmark 的性能瓶颈限制
• 可以支持更丰富的 TUI 交互(如平滑滚动、选区等)
缺点:
• 改动量极大(数千行),风险最高
• 需要深入理解终端 ANSI 序列和双缓冲技术
• 可能失去 Bubble Tea 生态优势(如鼠标支持、windows 兼容性需要自行实现)
• 开发周期长,短期内不可用
────────────────────────────
推荐路线
策略 │ 预估性能提升 │ 开发成本 │ 风险
───────────────────────────┼────────────────────────────────┼──────────────────────────┼──────────────────────────
下策 │ 30-50%(主要在重绘频控和缓存) │ 2-3天 │ 低
中策 │ 60-80%(增量渲染效果显著) │ 1-2周 │ 中
上策 │ 90%+(接近原生终端速度) │ 3-6周 │ 高
建议路线:
- 先落地下策(最快见效):
• 实现 message-level 渲染缓存(chatItem.lines 不重复计算)
• chatTailMessagesForView 二分法裁剪
• 在 ensureViewportContentForSize 中跳过重复 SetMessages
- 评估后再推中策:
• 将 chatList 改为增量 AppendMessage/UpdateLastMessage
• 渲染异步化为 tea.Cmd
• 预计算行数的前缀和来优化 chatTailMessagesForView
- 上策除非遇到天花板暂不推进——如果中策实施后即使 transcript 达 500+ 条依然流畅,就不需要重写架构。
需要我开始实现下策的具体代码吗?我可以从 message-level 缓存和 chatTailMessagesForView 的二分优化开始。
来自用户的建议和方案
────────────────────────────
TUI 卡顿问题三策分析法
诊断:根本瓶颈在哪里?
通过源码审计,我定位到几个关键热路径上的性能问题:
│ 瓶颈 │ 位置 │ 每次触发的频率
───┼───────────────────────┼─────────────────────────────────────────────────────────┼────────────────────────────
1 │ 全量消息重渲染 │ refreshViewportContentForSize() → chat.SetMessages() → │ 每次 Event*Delta 都触发
│ │ renderChatItemLines() │
2 │ Markdown 重复解析 │ ChatLines() → renderCard() → Markdown() → goldmark │ 同上,所有消息都重新
│ │ │ goldmark parse
3 │ chatTail 循环内渲染 │ chatTailMessagesForView() 的 while 循环每轮都调 │ 每轮 while 都全量重渲染
│ │ ChatLines() │
4 │ Update + View │ Update() 调 refreshViewportContent(),View() 又调 │ 每个 frame
│ 双份渲染 │ ensureViewportContentForSize() │
5 │ Composer.View() │ wrappedLineCount() + wrapComposerLine() + │ 每次 View() 调用
│ 行计算 │ promptLineAt() │
────────────────────────────
下策(最保守,最拘束)— 局部缓存 + 频控
思路:不动架构,仅在现有代码上做缓存和节流优化。
具体措施:
时只对新/变化的消息重新调用 renderChatItemLines(),已有消息直接从缓存拿。实现一个 generation 对比,只对 generation
差异的消息重渲染。
mainWidth && viewportLayoutHeight == bodyHeight 时,不在 View() 中重复 SetMessages,而是直接复用已经设置好的
chatList 的 items。
预计算行数,而不是每次都重渲染。
renderDeltaFrame(如 50ms),在 frame 内只更新 assembler 不触发全量重渲染,攒一批才刷到 chatList。
优点:
• 改动小,风险低,可以逐步落地
• 对现有代码侵入性小
缺点:
• 治标不治本,核心架构瓶颈未解决
• markdown 仍然是每次重新解析
• 当 transcript 积累到几百条消息时,即使有缓存,初次 SetMessages 仍会很慢
• View() 的 chat.View() 仍需遍历所有 items
────────────────────────────
中策(较为可行)— 增量渲染引擎 + 流式 Markdown 渲染
思路:重构渲染层,从"全量渲染"变为"仅渲染 delta 变化";引入流式 markdown 渲染避免每次重新解析全文。
具体措施:
• 不再每次 SetMessages() 重建所有 chatItem,而是维护一个 items 缓冲队列
• 新增 AppendMessage(msg) / UpdateLastMessage(msg) / RemoveMessage(id) 方法,只对变化的消息做
renderChatItemLines()
• chatList.View() 保持遍历现有 items 的逻辑不变
• 当前每次 delta 都 goldmark 全量解析。改为将 goldmark AST 缓存到 chatItem 中,新 delta 到来时,只对 AST 做增量
append 文本,重新从 AST 渲染到 ANSI
• 或者更实际的做法:利用 goldmark 的 parser.Context 和 Segment API,维护每个消息的 parser state
和已经渲染好的行缓存,增量追加
• 将 refreshViewportContentForSize() 从同步调用改为 tea.Cmd 异步触发,避免在 Update() 中阻塞事件处理
• 引入一个 renderTickCmd 以固定帧率(如 30fps)触发渲染,delta 事件只更新数据模型不直接触发票面渲染
• View() 中不直接调用 chat.View(),而是从预渲染好的 viewportContent 缓存中取
• 只有 renderTickCmd 触发时才刷新缓存
• 预计算每个 message 的行数(在 chatItem 中缓存 lineCount)
• 利用前缀和查找需要显示的消息,避免 while 循环中反复调昂贵的 ChatLines()
优点:
• 从 O(n) 降到 O(Δ + visible),n 是 transcript 总条数
• 大 transcript 场景(数百条消息)仍能保持流畅
• 架构上更合理,便于后续优化
缺点:
• 改动范围大,涉及 chatList、chatViewport、assembler、渲染管线
• 流式 goldmark AST 增量没有现成 API,需要自己包装(或者换轻量级 markdown 库)
• 异步渲染容易引入闪烁或时序问题
────────────────────────────
上策(最长远,收益最大,风险最高)— 重写 TUI 渲染架构,基于 Rust/Go 原生 2D 渲染
思路:承认 Bubble Tea + lipgloss + goldmark 在超大规模 Terminal UI 场景下的性能天花板,从底层重构。
具体措施:
• 维护一个 [row][col]cell 的二维网格,每个 cell 包含 rune + ANSI 样式
• View() 输出时只 diff 新旧网格,输出最小 ANSI diff
• 这类似 Tmux / Neovim 的渲染策略
• 基于有限状态机的增量 markdown 流式解析器,专门为 TUI 聊天场景优化
• 支持代码块、行内样式、链接这几个核心特性即可
• 解析输出直接写入字符网格,跳过中间 ANSI string 经过 lipgloss 再组合的阶段
• 利用 Bubble Tea 的 tea.Batch 组合 waitEventCmd 与定时的 renderFrameCmd
• 渲染 goroutine 输出最终网格快照,View() 直接从快照读
• chatList 不再持有所有消息的渲染行缓存,而是只持有可见窗口上下 N 条消息的渲染结果
• 滚动时动态加载 + 回收
• 借鉴 React Virtualized / Android RecyclerView 的 ViewHolder 模式
优点:
• 性能上限极高:渲染 O(visible_area) 而非 O(total_content)
• 超大 transcript(几千条)依然流畅
• 不再受 lipgloss / goldmark 的性能瓶颈限制
• 可以支持更丰富的 TUI 交互(如平滑滚动、选区等)
缺点:
• 改动量极大(数千行),风险最高
• 需要深入理解终端 ANSI 序列和双缓冲技术
• 可能失去 Bubble Tea 生态优势(如鼠标支持、windows 兼容性需要自行实现)
• 开发周期长,短期内不可用
────────────────────────────
推荐路线
策略 │ 预估性能提升 │ 开发成本 │ 风险
───────────────────────────┼────────────────────────────────┼──────────────────────────┼──────────────────────────
下策 │ 30-50%(主要在重绘频控和缓存) │ 2-3天 │ 低
中策 │ 60-80%(增量渲染效果显著) │ 1-2周 │ 中
上策 │ 90%+(接近原生终端速度) │ 3-6周 │ 高
建议路线:
• 实现 message-level 渲染缓存(chatItem.lines 不重复计算)
• chatTailMessagesForView 二分法裁剪
• 在 ensureViewportContentForSize 中跳过重复 SetMessages
• 将 chatList 改为增量 AppendMessage/UpdateLastMessage
• 渲染异步化为 tea.Cmd
• 预计算行数的前缀和来优化 chatTailMessagesForView
需要我开始实现下策的具体代码吗?我可以从 message-level 缓存和 chatTailMessagesForView 的二分优化开始。