Виртуальное окно просмотра для длинных бесед на 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 → #3905 | Ctrl+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), полный кадр при касании scrollbackscreen.ts/frame.ts— явные объекты Screen / Frame,cellAt/diffEach— посимвольное diffrender-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) — переживает 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.tsx | 764 | Основное окно просмотра + измерение + якорь прокрутки + отслеживание размера каждого элемента |
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, поэтому неизменённые элементы не перерисовываются.
Это эталонная реализация 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, адаптировав ResizeObserver → useBoxMetrics и создав собственный 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 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 уже обрабатывает 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 | Заголовок (черновик) | Область | Строк | Зависимости | Риск |
|---|---|---|---|---|---|
| #4146 | feat(cli): виртуальное окно просмотра для длинных бесед на ink 7 | основные примитивы + ASCII-полоса прокрутки с анимацией автопрятания + SGR колёсико мыши + флаг ui.useTerminalBuffer + связка MainContent/AppContainer + тесты | ~2800 строк | main | ✅ поставлено — typecheck чист, vitest зелёный |
| V.3 | test(integration): тесты регрессии захвата для стриминга / изменения размера / оболочки | порт 3 скриптов захвата из PR #3663 | ~2000 (только тесты) | #4146 | ожидает |
| V.4 | feat(cli): перетаскивание полосы прокрутки + клик для позиционирования | SGR hit-test на столбце полосы прокрутки. Требуются абсолютные координаты экрана — либо upstream getBoundingBox в ink 7, либо собственный обходчик yoga. Анимация автопрятания уже поставлена в #4146. | ~400 | #4146 | отложено — блокировка координат |
| V.5 | feat(cli): поиск / в приложении | подсветка в пределах окна просмотра + навигация n/N (шаблон TranscriptSearchBar от claude-code) | ~300 | #4146 | отложено |
| V.6 | feat(cli): режим альтернативного буфера (полный захват альтернативного экрана) | дополнительная настройка ui.useAlternateBuffer | ~500 | #4146 | отложено — требуется отдельное UX-решение |
| V.7 | research: сохранение 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(легаси) vstrue(виртуализация)
9. Открытые вопросы / необходимые решения
- Название настройки:
ui.useTerminalBuffer(совместимость с gemini-cli) илиui.virtualizedHistory(более описательно)? - Значение по умолчанию: поставить
false(опционально) или сначала поэтапное развёртывание через переменную окружения? - Эвристика статичности элементов: gemini-cli помечает только
headerкак статический. Стоит ли также помечать завершённые сообщения Gemini, результаты инструментов, которые больше не вpendingHistoryItems, и т.д.? - Поддержка мыши:
ScrollProviderв gemini-cli включает перетаскивание мыши для полосы прокрутки. Стоит переносить сейчас или отложить до V.4? - Совместимость с #3905:
PR #3905 (исправление зависания Ctrl+O) открыт и изменяет тот жеРешено: прогрессивное воспроизведение из #3905 попало вMainContent.tsx. Согласовать порядок слияния — вероятно, V.2 перебазируется поверх #3905.mainи сохранено в легаси-ветви<Static>вMainContent.tsx; VP-ветвь превосходит его для пользователей, включивших функцию, поскольку триггер зависания (полная перемонтировка 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, если доступен; в противном случае полагаться на функциональные тесты + бенчмарк. |
Легаси-путь <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 завершена