qwen-code Virtualized History 虚拟视口架构解析:基于 ink 7 的长会话渲染性能改造

发布时间:2026/9/13 15:45:18
qwen-code Virtualized History 虚拟视口架构解析:基于 ink 7 的长会话渲染性能改造 qwen-code Virtualized History 虚拟视口架构解析基于 ink 7 的长会话渲染性能改造【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code导读本文以 qwen-code 仓库中 docs/design/virtual-viewport/README.md 设计文档为主体深入剖析 qwen-code 为解决长会话终端界面闪烁flicker、滚动风暴scroll storm与界面卡死freeze问题而实施的Virtualized History虚拟视口架构改造。改造以 PR #4146 落地核心思路是从 gemini-cli 移植ScrollableListVirtualizedList组件体系并针对 ink 7 的 API 特性做适配。读完本文你将掌握虚拟视口解决的根本问题、ui.useTerminalBuffer配置的语义与适用边界、ResizeObserver → useBoxMetrics等关键适配手法以及键盘/鼠标滚动、自动隐藏滚动条的完整交互机制与源码级实现依据。1. 背景长会话渲染问题的根源qwen-code 的 TUI 界面基于 ink 构建历史消息通过 ink 的Static组件渲染。设计文档明确指出ink 的Static是append-only只追加的一旦挂载内容只能追加而不能替换。而 MainContent.tsx 在每次渲染时会把整个mergedHistory塞进Static。对于一个 1000 轮的长会话这意味着每次状态变化都要触发 1000 次HistoryItemDisplay的 React 渲染和 ink 布局计算。由此产生的用户可见问题在设计文档中有一张症状对照表Issue症状现行实现中的诱因#2950长会话持续出现上下滚动风暴每次刷新都会完整 remountStatic#3118切回窗口时持续闪烁clearTerminalhistoryRemountKey触发完整 remount#3007泛化的界面闪烁与 #3118 相同#3838UI 侧滚动条无界增长每次累积 delta 渲染都会新增行缺少视口逐出#3899 → #3905CtrlO 让终端卡死数秒部分修复的场景最终用setImmediate分块收尾PR #3905 的提交说明中明确记录了当时的取舍曾讨论过密封前缀 活尾巴sealed prefix live tail、真正的视口虚拟化true viewport virtualization、ANSI 输出缓存等方案但每种方案要么改变 UX要么需要一次架构级重写。设计文档给出的结论是这个架构级重写正是本设计要做的——即把虚拟视口作为问题的根治手段而非继续在Static全量重挂载上打补丁。2. 参照系两个开源 ink CLI 的既有解法在设计虚拟视口之前设计文档调研了两个同样基于 ink 的 CLI 项目作为可行性参照。2.1 claude-code自维护 ink forkclaude-code 在src/ink/下维护了自己的 ink fork包括ink.tsx——约 1722 行自定义主循环log-update.ts——约 773 行自定义 diff 渲染器带滚动区域DECSTBM优化以及触达 scrollback 时的全帧回退screen.ts/frame.ts——显式的 Screen / Frame 对象支持cellAt/diffEach单元格级 diffrender-to-screen.ts——暴露renderToScreen(node)可把任意节点树离线渲染到 Screen 对象这是渲染一次、缓存、回放即虚拟化的基础能力screens/REPL.tsx——visibleStreamingText只把完整行交给渲染器ScrollBox带scrollRef、cursorNavRefMarkdown.tsx——StreamingMarkdown在最后一个顶层块边界处拆分内容memoize 稳定前缀、只重新解析不稳定后缀token 缓存LRU-500在卸载→重挂载后依然存活虚拟滚动重挂载时无需重新词法分析。设计文档明确不复制这条路线整体 fork ink 的维护成本不可持续仅ink.tsx就有 1722 行外加自定义 reconciler上游每次 ink 修复都要手工合并。这种成本对 claude-code 的规模是合理的但对 qwen-code 不划算。2.2 gemini-cli以纯组件形式提供完整虚拟化列表gemini-cli 使用jrichman/ink6.6.9一个较小的 fork额外导出ResizeObserver和StaticRender并以纯组件形式交付了一套完整的虚拟化列表文件LoC职责components/shared/VirtualizedList.tsx764核心视口 测量 滚动锚定 逐项 resize 追踪components/shared/ScrollableList.tsx278包装VirtualizedList增加按键导航 平滑滚动 滚动条contexts/ScrollProvider.tsx469鼠标拖拽、滚动锁、焦点上下文hooks/useBatchedScroll.ts35合并同一 tick 内的滚动更新hooks/useAnimatedScrollbar.ts130滚动条淡入/淡出动画gemini-cli 的MainContent.tsx通过isAlternateBufferOrTerminalBuffer标志在两条渲染路径间切换虚拟化路径返回ScrollableList传统路径返回Static渲染的静态历史。HistoryItemDisplay被React.memo包裹未变化的条目不会重新渲染。设计文档的评价是这是生产级参照实现也是移植的蓝本。3. ink 7 能力核查不需要换 forkqwen-code 当时正处于chore/upgrade-ink-7升级分支上。设计文档核查了node_modules/ink/build/index.d.ts的导出结论如下✅useBoxMetrics(ref)返回{width, height, left, top, hasMeasured}布局变化时自动更新是ResizeObserver的功能等价物✅measureElement(node)——单次命令式测量✅useWindowSize——终端 resize✅useAnimation——用于滚动条淡入淡出✅Static、Box、Text等基础组件❌ResizeObserver组件/类——需要适配❌StaticRender——需要自研实现。结论ink 7 已具备所需的全部原语无需替换 fork。这一结论在仓库源码中得到印证在 packages/cli/src/ui/components/shared/VirtualizedList.tsx 中useBoxMetrics直接以import { type DOMElement, Box, Text, useBoxMetrics } from ink的方式从标准 ink 导入。4. 战略决策与文件地图4.1 决策移植 gemini-cli 方案而非自研或 fork设计文档给出的最终战略是将 gemini-cli 的ScrollableListVirtualizedList及配套 hooks/contexts 移植到 qwen-code把ResizeObserver适配为useBoxMetrics并自研一个StaticRender。被否决的备选方案及理由备选方案否决理由像 claude-code 一样 fork ink维护负担不可持续切换为jrichman/ink会回退正在进行的 ink 7 升级丢失 ink 7 的 React 19.2、reconciler 0.33 与新 diff 渲染器改进从零自研虚拟化是在重复发明约 1700 行已被验证的设计gemini-cli 的参照实现存在且可用4.2 PR #4146 之后的文件地图packages/cli/src/ui/ ├── components/shared/ │ ├── VirtualizedList.tsx [NEW] 核心视口 ASCII 滚动条 │ ├── ScrollableList.tsx [NEW] 键盘 鼠标滚轮包装层 │ └── StaticRender.tsx [NEW] React.memo 包装替代 gemini-cli 的 ink fork 导出 ├── hooks/ │ ├── useBatchedScroll.ts [NEW] 合并同一 tick 内的滚动更新 │ ├── useMouseEvents.ts [NEW] 启用 SGR 鼠标模式 解析 stdin 事件 │ └── useAnimatedScrollbar.ts [NEW] 滚动时高亮 thumb 空闲自动隐藏 ├── utils/ │ └── mouse.ts [NEW] SGR X11 鼠标事件解析器移植自 gemini-cli ├── components/MainContent.tsx [MOD] 增加虚拟化分支 稳定性 refs └── AppContainer.tsx [MOD] 将滚动相关 UI 状态喂入 context 门控 refreshStatic以上文件在当前仓库中均真实存在如 VirtualizedList.tsx、ScrollableList.tsx、StaticRender.tsx、useAnimatedScrollbar.ts、useMouseEvents.ts、utils/mouse.ts。推迟到后续 PR 的能力滚动条拖拽 点击定位需要屏幕绝对元素坐标受 stock ink 7 限制阻塞V.4 / V.7应用内/搜索参考 claude-code 的TranscriptSearchBar模式V.5独立的 alternate-buffer 设置项VP 本身已进入 alternate screen仅当兼容性报告需要时才考虑增加独立开关。5.ui.useTerminalBuffer配置项语义、默认值与生命周期5.1 设计文档中的设置草案// settings schema ui: { /** * Enables virtualized history rendering for long conversations. * When true, only items in the visible viewport are rendered through React; * scrolled-out items stay in the in-app scrollback model instead of the * host terminal scrollback buffer. * * Default: true. Users can opt out if they prefer host terminal scrollback. */ useTerminalBuffer?: boolean; // alias kept compat with gemini-cli }5.2 仓库中的最终 schema 实现该配置在 packages/cli/src/config/settingsSchema.ts 中落地关键属性如下type: booleanlabel: Virtualized History (reduces flicker on long sessions)category: UIrequiresRestart: true——因为该开关控制的是 Ink 的 alternate screen 生命周期运行期切换无法生效default: true——默认开启false为显式退出showInDialog: true——可在设置对话框中调整。schema 中的完整描述还揭示了该开关的连带影响启用时会话历史在应用内可滚动视口中渲染而非终端 scrollback 缓冲默认在兼容的交互式终端中开启用于规避长会话、CtrlO、CtrlE/CtrlF展开、窗口 resize、alt-tab 切回后的闪烁、滚动风暴与界面卡死屏幕阅读器模式和非交互式输出管道 stdout、CI仍使用追加式终端输出滚动方式Shift↑/↓按行、PgUp/PgDn按页、CtrlHome/End顶部/底部或鼠标滚轮同时启用鼠标交互菜单/对话框选项点击、悬停高亮、提示行点击定位光标、视口内拖拽选择文本双击/三击选择词/行松手复制按住 ShiftmacOS 上为 Option拖拽可改用终端原生选择单击打开指针下的 http(s) 链接其他 scheme 复制到剪贴板右键打开应用内上下文菜单Open Link / Copy Link Address / Copy Selection——这些交互由ui.mouseTracking控制关闭该设置则把鼠标完全还给终端。相关的两个配置项也在同一 schema 中ui.showScrollbardefault: true控制虚拟视口内自动隐藏滚动条的显隐滚动时出现、空闲淡出无需重启ui.mouseTrackingdefault: truerequiresRestart: true启用 SGR 鼠标追踪。注意其描述中与useTerminalBuffer的联动提示——关闭鼠标追踪后虚拟历史中的滚轮不再滚动 transcript可配合ui.useTerminalBuffer: false恢复终端原生 scrollback。schema 测试在 packages/cli/src/config/settingsSchema.test.ts 中锁定这些约束断言useTerminalBuffer存在、类型为 boolean、默认值为true、showInDialog为true、requiresRestart为true。5.3 启动期冻结决策与 Ink alternateScreen 生命周期同步由于配置决定与 Ink 的alternateScreen生命周期绑定AppContainer.tsx在启动时冻结该决策const [useTerminalBuffer] useState(() shouldUseVirtualViewport( settings.merged.ui?.useTerminalBuffer, config.getScreenReader(), isInteractiveTerminal(), ), );MainContent.tsx随后读取冻结的 UI 状态并切换路径const useVirtualScroll uiState.useTerminalBuffer; if (useVirtualScroll) { return ScrollableList .../; // virtualized } return Static .../; // existing path, untouched这条shouldUseVirtualViewport逻辑在 AppContainer.tsx 有对应实现并伴随完整的判定测试AppContainer.test.tsx覆盖未设置时默认进入 VP 模式屏幕阅读器模式保持在 Static 路径等场景。传统Static路径依然保留服务于三类用户/场景显式选择退出虚拟化的用户、屏幕阅读器模式、非交互式输出管道 stdout 或 CI。由于决策控制 Ink 的 alternate screen修改ui.useTerminalBuffer需要重启才生效——这一点在设计文档和 schema 的requiresRestart: true中都有明确说明。6. 从 gemini-cli 移植的关键适配手法6.1ResizeObserver→useBoxMetrics命令式观察者 → 声明式 Hookgemini-cli 用命令式模式观察容器尺寸变化const containerObserverRef useRefResizeObserver | null(null); const containerRefCallback useCallback((node: DOMElement | null) { containerObserverRef.current?.disconnect(); containerRef.current node; if (node) { const observer new ResizeObserver((entries) { const entry entries[0]; if (entry) { const newHeight Math.round(entry.contentRect.height); const newWidth Math.round(entry.contentRect.width); setContainerHeight((prev) (prev ! newHeight ? newHeight : prev)); setContainerWidth((prev) (prev ! newWidth ? newWidth : prev)); } }); observer.observe(node); containerObserverRef.current observer; } }, []);qwen-code 的适配是声明式的 ink 7 hookconst containerRef useRefDOMElement(null); const { width: containerWidth, height: containerHeight } useBoxMetrics(containerRef);useBoxMetrics内部已经处理了 attach/detach 与布局变化订阅命令式簿记代码全部消失。真实实现中VirtualizedList正是这样用useBoxMetrics(containerRef)拿到measuredContainerWidth/measuredContainerHeight并进一步支持外部传入显式containerHeight见 VirtualizedList.tsx。6.2 逐项 resize 追踪itemsObserveruseBoxMetrics无法 1:1 替换gemini-cli 用一个ResizeObserver观察 N 个条目节点通过WeakMap把 entry 路由回 keyconst nodeToKeyRef useRef(new WeakMapDOMElement, string()); const itemsObserver useMemo( () new ResizeObserver((entries) { setHeights((prev) { let next null; for (const entry of entries) { const key nodeToKeyRef.current.get(entry.target); if (key prev[key] ! Math.round(entry.contentRect.height)) { if (!next) next { ...prev }; next[key] Math.round(entry.contentRect.height); } } return next ?? prev; }); }), [], );由于useBoxMetrics是一个 ref 对应一个 hook 实例无法直接 1:1 替换。设计文档给出两个方案方案 A——把测量下推到VirtualizedListItem每个VirtualizedListItem本身是 memoized 组件在内部调用useBoxMetrics通过回调 prop 上报高度const VirtualizedListItem memo(({ itemKey, onHeightChange, ...props }) { const ref useRefDOMElement(null); const { height, hasMeasured } useBoxMetrics(ref); useEffect(() { if (hasMeasured) onHeightChange(itemKey, height); }, [itemKey, height, hasMeasured, onHeightChange]); return Box ref{ref}{...}/Box; });方案 B——父组件用measureElementuseLayoutEffect父组件保存可见条目的 refs每次渲染后跑一次 layout effect 测量useLayoutEffect(() { const newHeights: Recordstring, number { ...heights }; let changed false; for (const [key, ref] of itemRefs.current) { if (ref) { const { height } measureElement(ref); if (newHeights[key] ! height) { newHeights[key] height; changed true; } } } if (changed) setHeights(newHeights); });设计文档推荐方案 A职责分离更干净充分利用 ink 7 内置的变化检测规避每次渲染都测量一切的 measure storm 风险。从仓库实现看最终落地正是方案 AVirtualizedListItem是memo包裹的独立组件内部useBoxMetrics(itemRef)测量并在useLayoutEffect中通过onHeightChangeRef.current(itemKey, measuredHeight)上报——包括上报 0 高度折叠的 thought continuation 渲染高度为 0若不报告会留下缓存膨胀高度的空白间隙见 VirtualizedList.tsx。6.3StaticRender自研实现React.memo 稳定 keygemini-cli 从jrichman/ink导入StaticRender用法如下{shouldBeStatic ? ( StaticRender width{...} key{${itemKey}-static-${width}} {content} /StaticRender ) : ( content )}其语义是在给定宽度下把content渲染一次后续相同 key width 的渲染直接返回缓存结果。对 ink 7 而言等价物是React.memo 父组件保证不重渲染的稳定组件import { memo } from react; import { Box } from ink; interface StaticRenderProps { children: React.ReactElement; width?: number | string; } const StaticRender memo( ({ children, width }: StaticRenderProps) ( Box width{width} flexDirectioncolumn flexShrink{0} {children} /Box ), (prev, next) prev.children next.children prev.width next.width, );配合父组件的稳定keyprop${itemKey}-static-${width}children 或 width 变化时触发全新挂载否则 React 直接跳过重渲染。这就是核心能力静态条目如已完成的 Gemini 消息被测量并渲染一次后永远不会再走 React 渲染管线。仓库中的真实实现位于 packages/cli/src/ui/components/shared/StaticRender.tsx其注释精确说明了这一点它不是gemini-clijrichman/ink那种输出缓存真正的冻结由更内层的memo(HistoryItemDisplay)承担——稳定的历史条目引用让 React 将子树 reconcile 变成 no-opStaticRender的比较器只是廉价的 belt-and-braces 检查因为父组件的renderedItemsuseMemo每次重算都会分配新 JSX比较器对视口内条目很少命中。6.4 memoizeHistoryItemDisplay虚拟化生效的前提gemini-cli 的做法const MemoizedHistoryItemDisplay memo(HistoryItemDisplay);qwen-code 采用同样的模式。这是虚拟化真正跳过重渲染的前提——如果历史条目组件不是 memo 的即便视口只渲染少量条目其内部子树的每次 props 变化仍会导致全量重渲染。7. 核心数据结构与滚动算法源码级细节仓库中的VirtualizedList比设计文档更进一步实现了完整的滚动数学这些细节对理解虚拟视口的正确性至关重要7.1 高度表、偏移前缀和与二分查找heights是一个Recordstring, number记录每个条目 key 的实测高度每次渲染时基于heights缺失时回退estimatedItemHeight(i)计算offsets前缀和数组与totalHeight并做防御性钳制非有限或非正的估值被钳为 0避免污染依赖单调性假设的二分查找VirtualizedList.tsxfindLastLE用 O(log n) 二分替换了此前的 O(n) 线性扫描用于从 scrollTop 反查锚定条目firstIndexOfOffsetRun处理重合偏移段缓存 0 高度导致的相邻相等偏移滚动锚与渲染窗口起点都必须解析到该段第一个条目否则缓存 0 高度愈合时会出现空白间隙/滚动跳变VirtualizedList.tsx。7.2 底部吸附与自动滚动isStickingToBottom状态表示当前是否吸附在底部新内容到达时自动跟随通过useLayoutEffect检测内容此前是否恰好填满/超出视口以及列表是否增长在用户未向上翻阅时自动把滚动锚推进到SCROLL_TO_ITEM_ENDVirtualizedList.tsxtargetScrollIndex的锚定采用 React 官方推荐的渲染期调整状态Adjusting state while rendering模式确保目标行在首次绘制时即被锚定而非先闪一帧旧位置VirtualizedList.tsx。7.3 高度表的 LRU 式修剪为防止长会话中heights无限增长/clear、pending→completed 的 key 迁移会留下孤儿条目实现加入了门控修剪只有当heights大小超过max(8, 2 × data.length)时才运行 O(N) 清理稳态流式渲染中零开销修剪在useLayoutEffect中执行与数据变化同帧提交避免一帧过期偏移VirtualizedList.tsx。8. 键盘与鼠标交互从键盘滚动到 SGR 鼠标8.1 键盘滚动ScrollableList通过useKeypress绑定键盘滚动滚动量语义如下ScrollableList.tsx按键动作实现Shift↑向上 1 行scrollBy(-1)Shift↓向下 1 行scrollBy(1)PgUp向上一页scrollBy(-innerHeight)无测量时回退 20PgDn向下一页scrollBy(innerHeight)无测量时回退 20CtrlHome滚到顶部scrollTo(0)CtrlEnd滚到底部scrollToEnd()这与 schema 描述中的按键约定Shift↑/↓、PgUp/PgDn、CtrlHome/End完全一致。8.2 鼠标滚轮与滚动条拖拽的帧合并终端鼠标上报是每跨过一行发一个事件快速滚轮或拖拽会爆发式产生事件流。若逐事件同步应用每个事件都会触发一次 Ink reflow 终端 flush——这正是一顿一顿卡顿的根源。ScrollableList的处理是把意图累积进 refpendingWheelDelta累加相对滚轮刻度、pendingDragRow记录最新绝对行号由useFrameCoalescedFlush每帧至多应用一次最新意图同一帧内拖拽优先于滚轮。滚动条按下时还会cancelPendingScroll()丢弃排队中的滚轮/拖拽意图防止几毫秒前排入的 flush 把视图从用户点击的行拽走ScrollableList.tsx。鼠标事件处理left-press/left-release/move/scroll-up/scroll-down通过useMouseEvents接入并显式bypassVpGate: true——因为在 VP 模式下这个列表就是应用内滚轮的所有者。8.3 自动隐藏滚动条useAnimatedScrollbar.ts 是设计文档中带 auto-hide 动画的 ASCII 滚动条的实现。与 gemini-cli 的逐帧 RGB 颜色插值不同qwen-code 针对 stock ink 7 做了简化终端本来就不渲染平滑颜色渐变所以 hook 只暴露一个二值信号——thumb 当前是否高亮isVisible并配flashScrollbar()方法供滚动处理器调用。默认idleHideMs 1500即滚动输入 1.5 秒后 thumb 淡入暗轨道idleHideMs 0时禁用自动隐藏供测试断言。9. PR 序列分阶段交付与风险控制虚拟视口不是一次性大爆炸式交付而是按 PR 序列演进| PR | 标题草案 | 范围 | 规模 | 依赖 | 风险 | | -- | ------------ | ---- | ---- | ---- | ---- | |#4146| feat(cli): virtual viewport for long conversations on ink 7 | 核心原语 带 auto-hide 动画的 ASCII 滚动条 SGR 鼠标滚轮 ui.useTerminalBuffer门控 MainContent/AppContainer接线 测试 | ~2800 LoC |main| ✅已合入——typecheck 干净vitest 全绿 | |V.3| test(integration): capture-suite regressions for streaming / resize / shell | 从 PR #3663 移植 3 个 capture 脚本 | ~2000仅测试 | #4146 | pending | |V.4| feat(cli): scrollbar drag click-to-position | 滚动条列的 SGR 鼠标命中测试需要屏幕绝对坐标——要么给 ink 7 上游提交getBoundingBox要么自研 yoga walker。auto-hide 动画已在 #4146 交付 | ~400 | #4146 | deferred——坐标阻塞 | |V.5| feat(cli): in-app/search | 视口内高亮 n/N 导航claude-codeTranscriptSearchBar模式 | ~300 | #4146 | deferred | |V.6| feat(cli): dedicated alternate-buffer toggle | 默认流程不计划独立设置VP 已进入 alternate screen仅兼容性报告需要时再评估 | — | #4146 | deferred——仅兼容性驱动 | |V.7| research: preserve host terminal scrollback (dual-write) |jrichman/ink的overflowToBackbuffer仅存在于 fork可选上游 PR、自研 dual-write 或接受丢失 | — | #4146 | 结构上被 stock ink 7 阻塞 |设计文档的说明V.3集成测试对长会话回归覆盖仍然可取但已不再是默认切换的前置门槛V.4–V.6 用于补齐与 gemini-cli 的剩余差距V.7 是开放研究因为所需 ink 属性overflowToBackbuffer只存在于 gemini-cli 的jrichman/inkfork 中。10. 验证方案与已决议事项10.1 每个 PR 的强制验证在标记 ready for review 之前每个 PR 必须通过npm run typecheck --workspaceqwen-code/qwen-code——干净npm run lint --workspaceqwen-code/qwen-code——干净cd packages/cli npx vitest run——全部通过按项目工作流进行多轮无方向审计multi-round directionless audit10.2 端到端基准V.3 之后针对 1000 轮长会话测量首帧时间初始挂载 绘制CtrlO 切换延迟Resize 延迟流式渲染期间的每帧渲染时间对比useTerminalBuffer: false传统与true虚拟化10.3 已决议的关键决策设计文档的 approval checklist 记录了这些已定事项✅ 架构方向从 gemini-cli 移植§4✅ 设置名与默认值ui.useTerminalBuffer默认true可退出✅ 静态条目启发式isStaticItem{(item) item.id 0}已完成的 history 条目✅ 鼠标支持范围推迟到 V.4#4146 仅支持键盘滚动✅ 与 #3905 的合并顺序#3905 已合入main#4146 保留传统 progressive-replay 路径仅对 VP 用户生效并取代之✅ PR #4146 实现完成。10.4 与 #3905CtrlO 冻结修复的兼容性设计文档特别说明PR #3905 的 progressive-replay 已进入main并保留在MainContent.tsx的传统Static分支中对默认用户而言VP 分支取代它是因为冻结触发源完整 Static remount不再存在。11. 风险清单与缓解措施风险可能性缓解useBoxMetrics逐项使用在长列表上造成测量风暴中§6.2 方案 A 已逐项 memoize只有渲染窗口内的条目承担成本V.3 基准验证自研StaticRender遗漏jrichmanfork 处理的边界情况中审计 gemini-cli 的 StaticRender 源码若可得否则依赖功能测试 基准传统Static路径随新路径演进而漂移低特性开关门控保持双路径共存CI 通过设置矩阵运行双路径ink 7 上游仍有未修复缺陷低已通过chore/upgrade-ink-7处于 ink 7本 PR 不引入额外 ink 风险长会话在测量缓存中累积内存中heightsRecord 超过 N×viewport如 5×后增加 LRU 逐出V.3 基准验证其中测量缓存累积内存的风险在最终实现中以第 7.3 节的门控修剪机制闭环——heights超过max(8, 2 × data.length)即触发基于 key 集合的清理这正是设计文档所提 LRU 逐出的工程化落地。12. 总结qwen-code 的 Virtualized History 是一次以视口虚拟化根治长会话 TUI 渲染瓶颈的架构级改造问题根源是 inkStatic的 append-only 特性与MainContent全量喂入mergedHistory的组合战略选择是移植 gemini-cli 经过生产验证的VirtualizedList/ScrollableList体系而非 fork ink 或从零自研关键适配包括ResizeObserver → useBoxMetrics、逐条目测量下推方案 A、自研StaticRenderReact.memo 稳定 key、memo(HistoryItemDisplay)配置模型是ui.useTerminalBuffer默认true、requiresRestart: true配套ui.showScrollbar与ui.mouseTracking并保留传统Static路径作为屏幕阅读器与非交互输出的兜底交互完整性通过键盘滚动Shift↑/↓、PgUp/PgDn、CtrlHome/End、帧合并的 SGR 鼠标滚轮/拖拽、以及 1.5s 空闲自动隐藏的 ASCII 滚动条达成。对于希望继续深入源码的读者建议从 VirtualizedList.tsx核心视口与滚动数学、ScrollableList.tsx键盘/鼠标包装层、StaticRender.tsx静态条目冻结、useAnimatedScrollbar.ts滚动条动画以及配套测试 VirtualizedList.test.tsx、ScrollableList.test.tsx 开始阅读。【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考