Виртуальный 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 → #3905 | Ctrl+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), полный фрейм при касании scrollbackscreen.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,cursorNavRefMarkdown.tsxStreamingMarkdownразбивает содержимое по последней границе блока верхнего уровня, мемоизирует стабильный префикс, переразбирает только нестабильный суффикс
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.tsx | 764 | Ядро viewport + измерение + якорь прокрутки + отслеживание изменения размера каждого элемента |
components/shared/ScrollableList.tsx | 278 | Оборачивает VirtualizedList, добавляет навигацию клавишами + плавную прокрутку + скроллбар |
contexts/ScrollProvider.tsx | 469 | Перетаскивание мышью, блокировка прокрутки, контекст фокуса |
hooks/useBatchedScroll.ts | 35 | Объединяет обновления скролла в одном тике |
hooks/useAnimatedScrollbar.ts | 130 | Анимация появления/исчезновения скроллбара |
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, адаптировав ResizeObserver → useBoxMetrics и реализовав собственный 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 ResizeObserver → useBoxMetrics
Наблюдатель контейнера в 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 | Заголовок (черновик) | Область | Строки | Зависимости | Риск |
|---|---|---|---|---|---|
| #4146 | feat(cli): виртуальный viewport для длинных диалогов на ink 7 | базовые примитивы + ASCII-скроллбар с анимацией авто-скрытия + SGR колесо мыши + шлюз ui.useTerminalBuffer + MainContent/AppContainer связка + тесты | ~2800 LoC | main | ✅ отправлено — проверка типов чиста, vitest зелёный |
| V.3 | test(integration): набор регрессионных тестов для стриминга / изменения размера / оболочки | перенести 3 скрипта захвата из PR #3663 | ~2000 (только тесты) | #4146 | ожидает |
| V.4 | feat(cli): перетаскивание скроллбара и переход по клику | SGR-мышь: проверка попадания в колонку скроллбара. Требуются абсолютные координаты экрана — либо доработка getBoundingBox в ink 7, либо собственный обход yoga. Анимация авто-скрытия уже отправлена в #4146. | ~400 | #4146 | отложено — блокировка координат |
| V.5 | feat(cli): внутриприложенческий поиск / | подсветка в пределах viewport + навигация n/N (шаблон TranscriptSearchBar из claude-code) | ~300 | #4146 | отложено |
| V.6 | feat(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. Открытые вопросы / необходимые решения
- Название настройки:
ui.useTerminalBuffer(совместимость с gemini-cli) илиui.virtualizedHistory(более описательно)? - Значение по умолчанию: выпустить как
false(opt-in) или сначала поэтапное развертывание через env var? - Эвристика статических элементов: gemini-cli помечает только
headerкак статический. Стоит ли также помечать завершенные сообщения Gemini, результаты инструментов, которые больше не находятся вpendingHistoryItems, и т.д.? - Поддержка мыши:
ScrollProviderиз gemini-cli включает перетаскивание полосы прокрутки мышью. Стоит портировать сейчас или отложить до V.4? - Совместимость с #3905:
PR #3905 (исправление зависания Ctrl+O) открыт и изменяет тот же файлРешено: прогрессивный повтор #3905 попал вMainContent.tsx. Необходимо скоординировать порядок слияния — вероятно, V.2 будет перебазирован поверх #3905.mainи сохранен в legacy-ветке<Static>файлаMainContent.tsx; ветка VP заменяет его для opt-in пользователей, поскольку триггер зависания (полное перемонтирование Static) больше не применяется. - Совместимость с
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 завершена