Skip to Content
ДизайнVirtual ViewportВиртуальный viewport для длинных разговоров на ink 7

Виртуальный viewport для длинных разговоров на ink 7

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

1. Проблема

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

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

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

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

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

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

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

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

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

Поддерживает свой форк ink в src/ink/:

  • ink.tsx — 1722 строки собственного главного цикла
  • log-update.ts — 773 строки собственного дифференциального рендерера с оптимизацией области прокрутки (DECSTBM), полный фрейм при касании scrollback
  • screen.ts / frame.ts — явные объекты Screen / Frame, cellAt / diffEach — посимвольное сравнение
  • 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) — переживает размонтирование→монтирование, так что повторные монтирования при виртуальном скролле попадают в кэш без повторной лексической обработки

Почему мы не повторяем этот подход: форкать ink целиком — неподдерживаемая нагрузка (только 1722 строки ink.tsx, плюс собственный согласователь). Каждое исправление в оригинальном 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Ядро viewport + измерение + якорь прокрутки + отслеживание изменения размера каждого элемента
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, поэтому неизменённые элементы не перерисовываются. Это эталон промышленного уровня.

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 renderer)
Создать виртуализацию с нуляИзобретает ~1700 строк проверенного кода заново; эталон gemini-cli существует и работает

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

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

packages/cli/src/ui/ ├── components/shared/ │ ├── VirtualizedList.tsx [НОВЫЙ] ядро: область просмотра + ASCII-полоса прокрутки │ ├── ScrollableList.tsx [НОВЫЙ] обёртка для клавиатуры + колеса мыши │ └── StaticRender.tsx [НОВЫЙ] обёртка React.memo (заменяет экспорт форка ink из 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:

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

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 внутрь него; высоту передавайте наверх через callback-проп:

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 в родителе

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

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): виртуальный viewport для длинных диалогов на ink 7базовые примитивы + ASCII-скроллбар с анимацией авто-скрытия + SGR колесо мыши + шлюз ui.useTerminalBuffer + MainContent/AppContainer связка + тесты~2800 LoCmainотправлено — проверка типов чиста, vitest зелёный
V.3test(integration): набор регрессионных тестов для стриминга / изменения размера / оболочкиперенести 3 скрипта захвата из PR #3663~2000 (только тесты)#4146ожидает
V.4feat(cli): перетаскивание скроллбара и переход по кликуSGR-мышь: проверка попадания в колонку скроллбара. Требуются абсолютные координаты экрана — либо доработка getBoundingBox в ink 7, либо собственный обход yoga. Анимация авто-скрытия уже отправлена в #4146.~400#4146отложено — блокировка координат
V.5feat(cli): внутриприложенческий поиск /подсветка в пределах viewport + навигация n/N (шаблон TranscriptSearchBar из claude-code)~300#4146отложено
V.6feat(cli): режим альтернативного буфера (полный захват alt-screen)дополнительная настройка ui.useAlternateBuffer~500#4146отложено — требуется отдельное UX-решение
V.7исследование: сохранение прокрутки терминала хоста (двойная запись)@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 — все зелёные
  • Многораундовый безнаправленный аудит согласно рабочему процессу проекта

Сквозное тестирование (после V.3):

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

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

  1. Название настройки: ui.useTerminalBuffer (совместимость с gemini-cli) или ui.virtualizedHistory (более описательно)?
  2. Значение по умолчанию: выпустить как false (opt-in) или сначала поэтапное развертывание через env var?
  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 и сохранен в legacy-ветке <Static> файла MainContent.tsx; ветка VP заменяет его для opt-in пользователей, поскольку триггер зависания (полное перемонтирование 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, если доступен; в противном случае полагаться на функциональные тесты + бенчмарк.
Дрейф legacy-пути <Static> по мере развития нового путинизкаяФлаг-гейт функции поддерживает активность обоих путей; CI запускает оба через матрицу настроек.
У ink 7 все еще есть незакрытые баги в апстрименизкаяМы уже на ink 7 через chore/upgrade-ink-7; этот PR не вносит дополнительный риск для ink.
Длительные сессии накапливают память в кэшах измеренийсредняяДобавить вытеснение LRU для записи heights, как только размер превысит N×viewport (например, 5×). V.3 проверяет это в бенчмарке.

11. Чек-лист утверждения

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