Skip to Content
ДизайнVirtual ViewportВиртуальное окно просмотра для длинных бесед на ink 7

Виртуальное окно просмотра для длинных бесед на ink 7

Статус: реализовано, PR #4146 включает: основное окно просмотра, ASCII-полосу прокрутки с анимацией автопрятания, SGR-колёсико мыши, флаг ui.useTerminalBuffer, клавиши прокрутки. Перетаскивание полосы прокрутки / поиск в приложении / режим альтернативного буфера / двойная запись в scrollback терминала вынесены в V.3+ (см. §7). Автор: 秦奇 Ветка отслеживания: feat/virtual-viewport-on-ink7 (база: main)

1. Проблема

Несколько сообщений пользователей о мерцании / задержках сводятся к одной архитектурной особенности: <Static> в ink является только-добавляемым, а MainContent.tsx в qwen-code передаёт через него весь mergedHistory при каждом рендере. Для беседы из 1000 сообщений это 1000 React-рендеров HistoryItemDisplay + проходов компоновки ink при каждом изменении состояния.

Текущие симптомы, которые это вызывает:

ПроблемаСимптомТекущий вкладчик
#2950Долгий сеанс: непрерывный шторм прокрутки вверх/внизПолная перемонтировка Static при каждом обновлении
#3118Возврат в окно: мерцание не прекращаетсяclearTerminal + historyRemountKey++ вызывает полную перемонтировку
#3007Общее мерцание интерфейсаТо же, что #3118
#3838 (UI)Полоса прокрутки бесконтрольно растётКаждый кумулятивно-дельта-рендер добавляет строки; нет вытеснения из окна просмотра
#3899 → #3905Ctrl+O замораживал терминал на секундыЧастично исправленный случай, закреплён с помощью setImmediate chunking

PR #3905 явно отмечает:

Обсуждение альтернатив (запечатанный префикс + живой хвост, настоящая виртуализация окна просмотра, кэширование ANSI-вывода) рассматривалось, но каждое из них меняет UX или требует архитектурной переработки.

Эта архитектурная переработка и предлагается в данном дизайне.

2. Эталонные реализации

Исследовано два открытых ink-based CLI, которые уже решили (или обошли) ту же проблему:

2.1 claude-code (/Users/gawain/Documents/codebase/opensource/claude-code)

Имеет свой форк ink в src/ink/:

  • ink.tsx — 1722 строки кода собственного главного цикла
  • log-update.ts — 773 строки собственного diff-рендерера с оптимизацией через область прокрутки (DECSTBM), полный кадр при касании scrollback
  • screen.ts / frame.ts — явные объекты Screen / Frame, cellAt / diffEach — посимвольное diff
  • render-to-screen.ts — предоставляет renderToScreen(node) для вывода любого дерева узлов в объект Screen вне основного потока. Это основная возможность для «отрисовал один раз, закэшировал, воспроизвёл» — т.е. виртуализация.
  • screens/REPL.tsx:
    • visibleStreamingText = streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null — только завершённые строки передаются рендереру
    • ScrollBox с scrollRef, cursorNavRef
    • Markdown.tsx StreamingMarkdown разбивает содержимое по последней верхнеуровневой границе блока, запоминает стабильный префикс, переразбирает только нестабильный суффикс
  • Markdown.tsx кэш токенов (LRU-500) — переживает unmount→remount, поэтому повторные монтирования при виртуальной прокрутке попадают в кэш без повторного лексирования

Почему мы не повторяем этот подход: форкать ink полностью — это неустойчивая нагрузка на поддержку (1722 строки ink.tsx плюс собственный reconciler). Каждое исправление upstream ink приходится вручную сливать. Такие затраты оправданы для масштаба claude-code; не для qwen-code.

2.2 gemini-cli (/Users/gawain/Documents/codebase/opensource/gemini-cli)

Использует @jrichman/ink@6.6.9 (меньший форк, добавляющий ResizeObserver и StaticRender в экспорт), и поставляет полностью виртуализированный список как обычные компоненты:

ФайлСтрокиРоль
components/shared/VirtualizedList.tsx764Основное окно просмотра + измерение + якорь прокрутки + отслеживание размера каждого элемента
components/shared/ScrollableList.tsx278Обёртка VirtualizedList, добавляет навигацию по клавишам + плавную прокрутку + полосу прокрутки
contexts/ScrollProvider.tsx469Перетаскивание мышью, блокировка прокрутки, контекст фокуса
hooks/useBatchedScroll.ts35Объединение событий прокрутки в одном тике
hooks/useAnimatedScrollbar.ts130Анимация появления/исчезновения полосы прокрутки

MainContent.tsx переключается между двумя путями рендеринга через флаг isAlternateBufferOrTerminalBuffer:

if (isAlternateBufferOrTerminalBuffer) { return <ScrollableList data={virtualizedData} renderItem={renderItem} ... />; } return <Static items={[<AppHeader />, ...staticHistoryItems, ...lastResponseHistoryItems]}>...</Static>;

HistoryItemDisplay обёрнут в React.memo, поэтому неизменённые элементы не перерисовываются.

Это эталонная реализация production-уровня.

3. Проверка возможностей ink 7

qwen-code находится на ветке chore/upgrade-ink-7 (в разработке). Проверены экспорты node_modules/ink/build/index.d.ts:

  • useBoxMetrics(ref): {width, height, left, top, hasMeasured} — автообновление при изменении размеров. Функциональный эквивалент ResizeObserver.
  • measureElement(node) — однократное императивное измерение
  • useWindowSize — изменение размера терминала
  • useAnimation — для анимации исчезновения полосы прокрутки
  • Static, Box, Text и т.д.
  • ResizeObserver (компонент/класс) — нужна адаптация
  • StaticRender — нужна собственная реализация

Вывод: ink 7 содержит все необходимые примитивы. Смена форка не требуется.

4. Стратегическое решение

Перенести ScrollableList + VirtualizedList + вспомогающие хуки/контексты из gemini-cli в qwen-code, адаптировав ResizeObserveruseBoxMetrics и создав собственный StaticRender.

Отклонённые альтернативы:

АльтернативаПричина отклонения
Форк ink как claude-codeНеустойчивая нагрузка на поддержку
Переход на @jrichman/inkОткатывает текущее обновление ink 7; теряет преимущества ink 7 (React 19.2 + reconciler 0.33 + улучшенный diff-рендерер)
Создать виртуализацию с нуляИзобретает ~1700 строк уже проверенного дизайна; эталон gemini-cli существует и работает

5. Архитектура

Карта файлов после PR #4146

packages/cli/src/ui/ ├── components/shared/ │ ├── VirtualizedList.tsx [НОВЫЙ] основное окно просмотра + ASCII-полоса прокрутки │ ├── ScrollableList.tsx [НОВЫЙ] обёртка для клавиш + колёсика мыши │ └── StaticRender.tsx [НОВЫЙ] обёртка React.memo (заменяет экспорт форка gemini-cli) ├── hooks/ │ ├── useBatchedScroll.ts [НОВЫЙ] объединение событий прокрутки одного тика │ ├── useMouseEvents.ts [НОВЫЙ] включение SGR-режима мыши + разбор событий stdin │ └── useAnimatedScrollbar.ts [НОВЫЙ] вспышка бегунка при прокрутке + автопрятание при бездействии ├── utils/ │ └── mouse.ts [НОВЫЙ] парсер событий мыши SGR + X11 (порт из gemini-cli) ├── components/MainContent.tsx [ИЗМЕНЁН] добавлена ветвь виртуализации + рефы стабильности └── AppContainer.tsx [ИЗМЕНЁН] передача состояния, связанного с прокруткой, в контекст + управление refreshStatic

Отложено на последующие PR:

  • Перетаскивание полосы прокрутки + клик для позиционирования — нужны абсолютные координаты элементов на экране, блокируется ограничением stock-ink-7 (см. V.4 / V.7).
  • Поиск / в приложении — шаблон TranscriptSearchBar от claude-code (V.5).
  • Режим альтернативного буфера — фокус/блокировка в стиле contexts/ScrollProvider.tsx, с полным захватом альтернативного экрана (V.6).

Настройка (V.2)

// схема настроек ui: { /** * Включает виртуализированный рендеринг истории для длинных бесед. * Если true, через React отрисовываются только элементы, видимые в окне просмотра; * остальные остаются в буфере прокрутки терминала. * * По умолчанию: false. Опционально до подтверждения стабильности на длинных беседах. */ useTerminalBuffer?: boolean; // псевдоним сохранён для совместимости с gemini-cli }

MainContent.tsx читает настройку и переключает пути:

const useTerminalBuffer = uiState.settings?.ui?.useTerminalBuffer ?? false; if (useTerminalBuffer) { return <ScrollableList .../>; // виртуализированный } return <Static .../>; // существующий путь, без изменений

Легаси-путь <Static> остаётся без изменений — нет риска регрессии для пользователей, которые не включают функцию.

6. Ключевые адаптации из исходного кода gemini-cli

6.1 ResizeObserveruseBoxMetrics

Наблюдатель контейнера gemini-cli (императивный паттерн):

const containerObserverRef = useRef<ResizeObserver | 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; } }, []);

Наша адаптация (декларативный хук ink 7):

const containerRef = useRef<DOMElement>(null); const { width: containerWidth, height: containerHeight } = useBoxMetrics(containerRef);

useBoxMetrics уже обрабатывает attach/detach + подписку на изменение размеров; императивное управление исчезает.

6.2 Отслеживание размера каждого элемента (itemsObserver)

Сложнее. gemini-cli наблюдает за N узлами элементов через один ResizeObserver и связывает запись → ключ с помощью WeakMap:

const nodeToKeyRef = useRef(new WeakMap<DOMElement, 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 на хук, поэтому мы не можем заменить его 1:1. Два варианта:

Вариант A — вынести измерение в VirtualizedListItem

Каждый VirtualizedListItem уже работает как отдельный компонент (мемоизирован). Добавить useBoxMetrics внутрь него; сообщать высоту через колбэк-пропс:

const VirtualizedListItem = memo(({ itemKey, onHeightChange, ...props }) => { const ref = useRef<DOMElement>(null); const { height, hasMeasured } = useBoxMetrics(ref); useEffect(() => { if (hasMeasured) onHeightChange(itemKey, height); }, [itemKey, height, hasMeasured, onHeightChange]); return <Box ref={ref}>{...}</Box>; });

Вариант B — использовать measureElement + useLayoutEffect в родителе

Родитель хранит refs для видимых элементов, выполняет layout-effect после каждого рендера для их измерения. Менее реактивно, но проще:

useLayoutEffect(() => { const newHeights: Record<string, 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. Избегает риска «шторма измерений», когда каждый рендер измеряет всё.

6.3 StaticRender — собственная реализация

gemini-cli импортирует StaticRender из @jrichman/ink. Изучая использование в VirtualizedList.tsx:

{shouldBeStatic ? ( <StaticRender width={...} key={`${itemKey}-static-${width}`}> {content} </StaticRender> ) : ( content )}

Семантика: отрендерить content один раз при заданной ширине; последующие рендеры с тем же ключом + шириной возвращают кэшированный результат.

Для 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} flexDirection="column" flexShrink={0}> {children} </Box> ), (prev, next) => prev.children === next.children && prev.width === next.width, );

В сочетании со стабильным пропсом key родителя (${itemKey}-static-${width}) изменение children или width вызывает новое монтирование; в противном случае React пропускает перерисовку.

Это ключевая возможность: элементы, которые ЯВЛЯЮТСЯ статическими (например, завершённые сообщения Gemini), измеряются + рендерятся один раз и никогда не проходят повторный обход React.

6.4 Мемоизация HistoryItemDisplay

gemini-cli делает:

const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);

Тот же паттерн в qwen-code. Необходимо, чтобы виртуализация действительно пропускала повторные рендеры.

7. Последовательность PR

PRЗаголовок (черновик)ОбластьСтрокЗависимостиРиск
#4146feat(cli): виртуальное окно просмотра для длинных бесед на ink 7основные примитивы + ASCII-полоса прокрутки с анимацией автопрятания + SGR колёсико мыши + флаг ui.useTerminalBuffer + связка MainContent/AppContainer + тесты~2800 строкmainпоставлено — typecheck чист, vitest зелёный
V.3test(integration): тесты регрессии захвата для стриминга / изменения размера / оболочкипорт 3 скриптов захвата из PR #3663~2000 (только тесты)#4146ожидает
V.4feat(cli): перетаскивание полосы прокрутки + клик для позиционированияSGR hit-test на столбце полосы прокрутки. Требуются абсолютные координаты экрана — либо upstream getBoundingBox в ink 7, либо собственный обходчик yoga. Анимация автопрятания уже поставлена в #4146.~400#4146отложено — блокировка координат
V.5feat(cli): поиск / в приложенииподсветка в пределах окна просмотра + навигация n/N (шаблон TranscriptSearchBar от claude-code)~300#4146отложено
V.6feat(cli): режим альтернативного буфера (полный захват альтернативного экрана)дополнительная настройка ui.useAlternateBuffer~500#4146отложено — требуется отдельное UX-решение
V.7research: сохранение scrollback хост-терминала (двойная запись)@jrichman/ink’s overflowToBackbuffer существует только в форке. Варианты: upstream PR в ink 7, собственная двойная запись, или смириться с потерей. Исследование.#4146структурно заблокировано stock ink 7

V.3 (интеграционные тесты) — оставшийся элемент критического пути перед переключением значения по умолчанию. V.4–V.6 закрывают оставшиеся разрывы с gemini-cli; V.7 — открытое исследование, так как необходимый пропс ink (overflowToBackbuffer) существует только в форке @jrichman/ink gemini-cli.

8. План верификации

На каждый PR (обязательно перед установкой статуса “готов к ревью”):

  • npm run typecheck --workspace=@qwen-code/qwen-code — чисто
  • npm run lint --workspace=@qwen-code/qwen-code — чисто
  • cd packages/cli && npx vitest run — всё зелёное
  • Многораундовый аудит без направления согласно рабочему процессу проекта

End-to-end (после V.3):

  • Бенчмарк длинной беседы: сессия из 1000 сообщений, измерить
    • Время первого отображения (начальное монтирование + отрисовка)
    • Задержка переключения Ctrl+O
    • Задержка изменения размера
    • Время рендера кадра во время стриминга
  • Сравнить useTerminalBuffer: false (легаси) vs true (виртуализация)

9. Открытые вопросы / необходимые решения

  1. Название настройки: ui.useTerminalBuffer (совместимость с gemini-cli) или ui.virtualizedHistory (более описательно)?
  2. Значение по умолчанию: поставить false (опционально) или сначала поэтапное развёртывание через переменную окружения?
  3. Эвристика статичности элементов: gemini-cli помечает только header как статический. Стоит ли также помечать завершённые сообщения Gemini, результаты инструментов, которые больше не в pendingHistoryItems, и т.д.?
  4. Поддержка мыши: ScrollProvider в gemini-cli включает перетаскивание мыши для полосы прокрутки. Стоит переносить сейчас или отложить до V.4?
  5. Совместимость с #3905: PR #3905 (исправление зависания Ctrl+O) открыт и изменяет тот же MainContent.tsx. Согласовать порядок слияния — вероятно, V.2 перебазируется поверх #3905. Решено: прогрессивное воспроизведение из #3905 попало в main и сохранено в легаси-ветви <Static> в MainContent.tsx; VP-ветвь превосходит его для пользователей, включивших функцию, поскольку триггер зависания (полная перемонтировка Static) больше не применяется.
  6. Совместимость с chore/re-upgrade-ink-7-0-3: PR #4146 строится поверх него. После того как #4119 (PR переобновления на ink 7.0.3) будет слит в main, база PR #4146 будет переориентирована на main.

10. Риски

РискВероятностьСмягчение
useBoxMetrics на каждый элемент создаёт шторм измерений на длинных спискахсредняяВариант A в §6.2 уже мемоизирует каждый элемент; только элементы в окне рендера платят стоимость. Бенчмарк в V.3.
Собственная реализация StaticRender упускает граничный случай, обработанный форком @jrichmanсредняяАудит исходного кода StaticRender gemini-cli, если доступен; в противном случае полагаться на функциональные тесты + бенчмарк.
Легаси-путь <Static> дрейфует по мере развития нового путинизкаяФлаг-конфигурация держит оба пути активными; CI запускает оба через матрицу настроек.
ink 7 всё ещё имеет неисправленные ошибки upstreamнизкаяМы уже используем ink 7 через chore/upgrade-ink-7; этот PR не добавляет дополнительных рисков, связанных с ink.
Долгоживущие сессии накапливают память в кэшах измеренийсредняяДобавить LRU-вытеснение для записи heights, когда размер превышает N×окно просмотра (например, 5×). Бенчмарк в V.3.

11. Контрольный список согласования

  • Утверждено архитектурное направление — порт с gemini-cli (§4)
  • Название настройки + значение по умолчанию согласованы — ui.useTerminalBuffer, по умолчанию false (опционально)
  • Эвристика для статических элементов — isStaticItem={(item) => item.id > 0} (завершённые элементы истории)
  • Область поддержки мыши — отложено до V.4; прокрутка только с клавиатуры в #4146
  • Порядок слияния с #3905 (§9.5) — #3905 уже в main; #4146 сохраняет устаревший путь прогрессивного воспроизведения и заменяет его только для пользователей VP
  • Реализация PR #4146 завершена
Last updated on