Глава 13: Терминальный UI
Зачем строить собственный рендерер?
Терминал — это не браузер. Здесь нет DOM, нет CSS-движка, нет композитора, нет графического конвейера retained-mode. Есть поток байтов, идущий в stdout, и поток байтов, приходящий из stdin. Всё между этими двумя потоками — компоновка, стилизация, диффинг, hit-testing, прокрутка, выделение — приходится изобретать с нуля.
Claude Code нужен реактивный UI. В нём есть поле ввода prompt, потоковый markdown-вывод, диалоги разрешений, индикаторы прогресса, прокручиваемые списки сообщений, подсветка поиска и редактор в vim-режиме. React — очевидный выбор для описания такого дерева компонентов. Но React нужен хост-окружение, куда рендерить, а терминалы его не предоставляют.
Ink — стандартный ответ: React-рендерер для терминалов, построенный на Yoga для flexbox-разметки. Claude Code начал с Ink, а затем форкнул его до неузнаваемости. Стандартная версия создаёт один JavaScript-объект на ячейку на каждый кадр — на терминале 200x120 это 24 000 объектов, создаваемых и собираемых сборщиком мусора каждые 16 мс. Она делает diff на уровне строк, сравнивая целые строки ANSI-кодированного текста. У неё нет понятия blit-оптимизации, нет двойной буферизации, нет отслеживания dirty-на уровне ячеек. Для простого CLI-дашборда, обновляющегося раз в секунду, этого достаточно. Для LLM-агента, стримящего токены с частотой 60 fps, пока пользователь прокручивает беседу из сотен сообщений, — это тупик.
То, что осталось в Claude Code, — это кастомный движок рендеринга, разделяющий концептуальную ДНК Ink — React reconciler, Yoga layout, ANSI-вывод — но переопределяющий критический путь: упакованные typed arrays вместо объекта на ячейку, intern-пулы на основе пулов вместо строки на кадр, двойная буферизация с диффингом на уровне ячеек и оптимизатор, который объединяет соседние terminal writes в минимальные escape-последовательности.
В результате всё работает на 60 fps на терминале шириной 200 колонок, пока Claude стримит токены. Чтобы понять как именно, нужно рассмотреть четыре слоя: кастомный DOM, с которым reconciler React работает, pipeline рендеринга, превращающий этот DOM в terminal output, pool-based memory management, который удерживает систему живой в сессиях на часы без утопления в garbage collection, и архитектуру компонентов, связывающую всё это вместе.
Кастомный DOM
Reconciler React нужен объект, с которым можно reconciliation. В браузере это DOM. В терминале Claude Code — это кастомное древовидное представление в памяти с семью типами элементов и одним типом текстового узла.
Типы элементов напрямую соответствуют концепциям терминального рендеринга:
ink-root— корень документа, по одному на каждый Ink instanceink-box— контейнер flexbox, терминальный эквивалент<div>ink-text— текстовый узел с Yoga measure-функцией для переноса строкink-virtual-text— вложенный стилизованный текст внутри другого текстового узла (автоматически повышается изink-text, когда находится в текстовом контексте)ink-link— гиперссылка, рендеримая через OSC 8 escape-последовательностиink-progress— индикатор прогрессаink-raw-ansi— предварительно отрендеренный ANSI-контент с известными размерами, используемый для подсвеченных code blocks
Каждый DOMElement содержит состояние, которое нужно rendering pipeline:
// Для иллюстрации — фактический интерфейс существенно шире
interface DOMElement {
yogaNode: YogaNode; // Узел flexbox-разметки
style: Styles; // CSS-подобные свойства, сопоставленные Yoga
attributes: Map<string, DOMNodeAttribute>;
childNodes: (DOMElement | TextNode)[];
dirty: boolean; // Требует повторного рендера
_eventHandlers: EventHandlerMap; // Отделены от атрибутов
scrollTop: number; // Императивное состояние прокрутки
pendingScrollDelta: number;
stickyScroll: boolean;
debugOwnerChain?: string; // Стек React-компонентов для отладки
}
Разделение _eventHandlers и attributes сделано намеренно. В React идентичность обработчиков меняется на каждом рендере (если только их вручную не memoize). Если бы обработчики хранились как атрибуты, каждый рендер помечал бы узел dirty и запускал полный repaint. Хранение их отдельно позволяет commitUpdate у reconciler обновлять обработчики, не делая узел dirty.
Функция markDirty() — мост между мутациями DOM и rendering pipeline. Когда содержимое любого узла меняется, markDirty() проходит вверх по всем предкам, выставляя dirty = true на каждом элементе и вызывая yogaNode.markDirty() на leaf-текстовых узлах. Так одно изменение символа в глубоко вложенном текстовом узле планирует повторный рендер всего пути к корню — но только этого пути. Соседние поддеревья остаются чистыми и могут быть blit-скопированы из предыдущего кадра.
Тип элемента ink-raw-ansi заслуживает отдельного упоминания. Когда code block уже был syntax-highlighted (и выдал ANSI escape-последовательности), повторно парсить эти последовательности, чтобы извлечь символы и стили, было бы расточительно. Вместо этого предварительно подсвеченный контент оборачивается в узел ink-raw-ansi с атрибутами rawWidth и rawHeight, которые сообщают Yoga точные размеры. Rendering pipeline пишет сырой ANSI-контент напрямую в output buffer, не раскладывая его на отдельные стилизованные символы. Это делает syntax-highlighted code blocks практически бесплатными после первого прохода подсветки — самый дорогой визуальный элемент UI оказывается ещё и самым дешёвым в рендере.
Measure-функцию ink-text важно понимать, потому что она выполняется внутри layout-pass Yoga, который синхронный и блокирующий. Функция получает доступную ширину и должна вернуть размеры текста. Она выполняет перенос строк (с учётом wrap style prop: wrap, truncate, truncate-start, truncate-middle), учитывает границы grapheme cluster (чтобы не разрезать emoji из нескольких codepoint на разные строки), корректно измеряет CJK-символы двойной ширины (каждый считается за 2 колонки) и вычищает ANSI escape-коды из вычисления ширины (escape-последовательности имеют нулевую визуальную ширину). Всё это должно укладываться в микросекунды на узел, потому что беседа с 50 видимыми текстовыми узлами означает 50 вызовов measure-функции на один layout-pass.
Контейнер React Fiber
Мост reconciler использует react-reconciler для создания custom host config. Это тот же API, что используют React DOM и React Native. Ключевое отличие: Claude Code работает в режиме ConcurrentRoot.
createContainer(rootNode, ConcurrentRoot, ...)
ConcurrentRoot включает concurrent-возможности React — Suspense для ленивой загрузки syntax highlighting, transitions для неблокирующих обновлений состояния во время стриминга. Альтернатива, LegacyRoot, заставила бы рендер быть синхронным и блокировала бы event loop при тяжёлых повторных разборах markdown.
Методы host config сопоставляют операции React с кастомным DOM:
createInstance(type, props)создаётDOMElementчерезcreateNode(), применяет начальные стили и атрибуты, навешивает event handlers и сохраняет owner chain React-компонента для debug-атрибуции. Owner chain хранится какdebugOwnerChainи используется режимомCLAUDE_CODE_DEBUG_REPAINTS, чтобы приписывать full-screen reset конкретным компонентамcreateTextInstance(text)создаётTextNode— но только если мы находимся в текстовом контексте. Reconciler требует, чтобы raw strings были обёрнуты в<Text>. Попытка создать текстовый узел вне текстового контекста бросает исключение, ловя класс ошибок на этапе reconciliation, а не на этапе рендераcommitUpdate(node, type, oldProps, newProps)делает diff старых и новых props через shallow comparison, а затем применяет только изменившееся. Стили, атрибуты и event handlers проходят по своим путям обновления. Функция diff возвращаетundefined, если ничего не изменилось, полностью избегая ненужных мутаций DOMremoveChild(parent, child)удаляет узел из дерева, рекурсивно освобождает Yoga nodes (вызываяunsetMeasureFunc()передfree(), чтобы не обращаться к освобождённой WASM-памяти) и уведомляет focus managerhideInstance(node)/unhideInstance(node)переключаетisHiddenи меняет состояние Yoga-узла междуDisplay.NoneиDisplay.Flex. Это механизм React для переходов Suspense fallbackresetAfterCommit(container)— критический хук: он вызываетrootNode.onComputeLayout()для запуска Yoga, а затемrootNode.onRender()для планирования terminal paint
Reconciler отслеживает два счётчика производительности на каждый цикл commit: время layout Yoga (lastYogaMs) и общее время commit (lastCommitMs). Они попадают в FrameEvent, который сообщает класс Ink, позволяя мониторить производительность в продакшене.
Система событий повторяет browser-модель capture/bubble. Класс Dispatcher реализует полную event propagation с тремя фазами: capture (от корня к цели), at-target и bubble (от цели к корню). Типы событий сопоставляются с приоритетами планирования React — discrete для клавиатуры и кликов (высший приоритет, обрабатываются немедленно), continuous для прокрутки и resize (могут быть отложены). Dispatcher оборачивает всю обработку событий в reconciler.discreteUpdates() для корректной batching-модели React.
Когда вы нажимаете клавишу в терминале, соответствующий KeyboardEvent проходит через кастомное DOM-дерево, всплывая от сфокусированного элемента к корню ровно так же, как keyboard event всплывал бы через элементы browser DOM. Любой обработчик на пути может вызвать stopPropagation() или preventDefault(), и семантика идентична спецификации браузера.
Rendering Pipeline
Каждый кадр проходит семь стадий, каждая из которых измеряется отдельно:
Каждая стадия таймится отдельно и попадает в FrameEvent.phases. Эта поэтапная instrumentation необходима для диагностики проблем производительности: когда кадр занимает 30 мс, нужно знать, узкое место — это повторный расчёт текста в Yoga (стадия 2), обход большого dirty-поддерева рендерером (стадия 3) или backpressure stdout из медленного терминала (стадия 7). Ответ определяет исправление.
Стадия 1: React commit и Yoga layout. Reconciler обрабатывает state updates и вызывает resetAfterCommit. Это устанавливает ширину корневого узла в terminalColumns и запускает yogaNode.calculateLayout(). Yoga вычисляет всё дерево flexbox за один проход, следуя спецификации CSS flexbox: он разрешает flex-grow, flex-shrink, padding, margin, gap, выравнивание и перенос по всем узлам. Результаты — getComputedWidth(), getComputedHeight(), getComputedLeft(), getComputedTop() — кэшируются на каждом узле. Для ink-text узлов Yoga вызывает кастомную measure-функцию (measureTextNode) во время layout, вычисляющую размеры текста через перенос строк и измерение grapheme. Это самая дорогая операция на узел: она должна учитывать Unicode grapheme clusters, CJK-символы двойной ширины, emoji-последовательности и ANSI escape-коды, встроенные в текст.
Стадия 2: DOM-to-screen. Рендерер проходит по DOM-дереву в depth-first порядке, записывая символы и стили в Screen buffer. Каждый символ становится упакованной ячейкой. Выход — полный кадр: каждая ячейка терминала имеет определённый символ, стиль и ширину.
Стадия 3: Overlay. Выделение текста и подсветка поиска изменяют буфер экрана на месте, переключая style IDs на совпадающих ячейках. Выделение применяет inverse video, чтобы создать привычный вид «подсвеченного текста». Подсветка поиска применяет более агрессивную визуальную обработку: inverse + жёлтый foreground + жирный + подчёркивание для текущего совпадения, только inverse для остальных совпадений. Это загрязняет буфер — это отслеживается флагом prevFrameContaminated, чтобы следующий кадр знал, что fast-path blit нужно пропустить. Такое загрязнение — сознательный компромисс: изменение буфера на месте позволяет избежать отдельного overlay buffer (экономя 48 КБ на терминале 200x120), ценой одного полностью повреждённого кадра после снятия overlay.
Стадия 4: Diff. Новый экран сравнивается ячейка-за-ячейкой с экраном front frame. Только изменившиеся ячейки порождают output. Сравнение — это два integer comparison на ячейку (две упакованные Int32 words), а diff идёт по damage rectangle, а не по всему экрану. В стабильном кадре (мигает только спиннер) это может дать патчи всего для 3 ячеек из 24 000. Каждый патч — объект { type: 'stdout', content: string }, содержащий последовательность перемещения курсора и ANSI-кодированный контент ячейки.
Стадия 5: Optimize. Соседние патчи в одной строке объединяются в одну запись. Избыточные перемещения курсора убираются — если патч N заканчивается в колонке 10, а патч N+1 начинается в колонке 11, курсор уже на нужной позиции, и sequence перемещения не нужен. Переходы стилей предварительно сериализуются через кэш StylePool.transition(), так что смена с «жирный красный» на «тусклый зелёный» — это один cached string lookup, а не операция diff-and-serialize. Обычно оптимизатор уменьшает число байтов на 30–50% по сравнению с наивным поячейковым выводом.
Стадия 6: Write. Оптимизированные патчи сериализуются в ANSI escape-последовательности и записываются в stdout одним write(), обёрнутым в synchronous update markers (BSU/ESU) на терминалах, которые это поддерживают. BSU (Begin Synchronized Update, ESC [ ? 2026 h) говорит терминалу буферизовать весь последующий вывод, а ESU (ESC [ ? 2026 l) — сбросить буфер. Это устраняет видимый tearing на терминалах, поддерживающих протокол, — весь кадр появляется атомарно.
Каждый кадр сообщает разбивку времени через объект FrameEvent:
interface FrameEvent {
durationMs: number;
phases: {
renderer: number; // DOM-to-screen
diff: number; // Сравнение экранов
optimize: number; // Слияние патчей
write: number; // Запись в stdout
yoga: number; // Вычисление layout
};
yogaVisited: number; // Количество пройденных узлов
yogaMeasured: number; // Узлы, где выполнился measure()
yogaCacheHits: number; // Узлы с кэшированным layout
flickers: FlickerEvent[]; // Атрибуция full-reset
}
Когда включён CLAUDE_CODE_DEBUG_REPAINTS, full-screen reset приписываются своему исходному React-компоненту через findOwnerChainAtRow(). Это терминальный аналог React DevTools «Highlight Updates» — он показывает, какой компонент заставил весь экран перерисоваться, а это самое дорогое, что может случиться в rendering pipeline.
Blit-оптимизация заслуживает особого внимания. Когда узел не dirty и его позиция не изменилась с прошлого кадра (проверяется через node cache), рендерер копирует ячейки напрямую из prevScreen в текущий экран вместо повторного рендера поддерева. Это делает стабильные кадры чрезвычайно дешёвыми — на типичном кадре, где тикает только спиннер, blit покрывает 99% экрана, а 3–4 ячейки самого спиннера рендерятся заново с нуля.
Blit отключается в трёх случаях:
prevFrameContaminatedравно true — overlay выделения или подсветка поиска изменили буфер front frame на месте, поэтому этим ячейкам нельзя доверять как «правильному» предыдущему состоянию- Удалён absolute-positioned узел — абсолютное позиционирование означает, что узел мог перекрывать ячейки не только своих соседей, и эти ячейки нужно перерендерить из элементов, которым они реально принадлежат
- Layout сдвинулся — кэшированная позиция любого узла отличается от текущей вычисленной позиции, а значит blit копировал бы ячейки в неправильные координаты
Damage rectangle (screen.damage) отслеживает bounding box всех записанных ячеек во время рендера. Diff смотрит только на строки внутри этого прямоугольника, пропуская полностью неизменившиеся области. На терминале высотой 120 строк, где стриминговое сообщение занимает строки 80–100, diff проверяет 20 строк вместо 120 — это сокращение работы сравнения в 6 раз.
Двойная буферизация и планирование кадров
Класс Ink поддерживает два frame buffer:
private frontFrame: Frame; // Сейчас отображается на терминале
private backFrame: Frame; // В него идёт рендер
Каждый Frame содержит:
screen: Screen— буфер ячеек (упакованныйInt32Array)viewport: Size— размеры терминала на момент рендераcursor: { x, y, visible }— где паркуется курсор терминалаscrollHint— hint оптимизации DECSTBM (scroll region) в режиме alt-screenscrollDrainPending— остался ли уScrollBoxpending scroll delta для обработки
После каждого рендера кадры меняются местами: backFrame = frontFrame; frontFrame = newFrame. Старый front frame становится следующим back frame, предоставляя prevScreen для blit-оптимизации и базовую линию для diff на уровне ячеек.
Такая double-buffer-архитектура убирает аллокации. Вместо создания нового Screen на каждый кадр рендерер переиспользует буфер back frame. Смена — это присваивание указателя. Паттерн заимствован из графического программирования, где double buffering предотвращает tearing, гарантируя, что дисплей читает полный кадр, пока рендерер пишет в другой. В терминальном контексте tearing — не главная проблема (протокол BSU/ESU решает её), а проблема — GC pressure от аллокации и выброса объектов Screen, содержащих 48 КБ+ typed arrays каждые 16 мс.
Планирование рендера использует lodash throttle с интервалом 16 мс (примерно 60 fps), с включёнными leading и trailing edges:
const deferredRender = () => queueMicrotask(this.onRender);
this.scheduleRender = throttle(deferredRender, FRAME_INTERVAL_MS, {
leading: true,
trailing: true,
});
Отложение через microtask не случайно. resetAfterCommit выполняется до фазы layout effects React. Если бы рендерер здесь работал синхронно, он пропустил бы декларации курсора, сделанные в useLayoutEffect. Microtask выполняется после layout effects, но в том же event-loop tick — терминал видит один целостный кадр.
Для операций прокрутки отдельный setTimeout на 4 мс (FRAME_INTERVAL_MS >> 2) даёт более быстрые scroll frames, не конфликтуя с throttle. Мутации прокрутки обходят React полностью: ScrollBox.scrollBy() напрямую мутирует свойства DOM-узла, вызывает markDirty() и планирует рендер через microtask. Никаких state update React, никакой reconciliation overhead, никакого повторного рендера всего списка сообщений из-за одного wheel event.
Обработка resize синхронная, не debounced. Когда терминал меняет размер, handleResize немедленно обновляет размеры, чтобы сохранить консистентность layout. Для режима alt-screen он сбрасывает frame buffers и откладывает ERASE_SCREEN в следующий атомарный BSU/ESU paint block вместо немедленной записи. Синхронная запись erase оставила бы экран пустым на те ~80 мс, которые занимает рендер; отложение в атомарный блок означает, что старый контент остаётся видимым, пока новый кадр полностью не готов.
Управление alt-screen добавляет ещё один слой. Компонент AlternateScreen входит в alternate screen buffer DEC 1049 при mount, ограничивая высоту строками терминала. Он использует useInsertionEffect, а не useLayoutEffect, чтобы гарантировать, что escape-последовательность ENTER_ALT_SCREEN достигнет терминала до первого кадра рендера. Использование useLayoutEffect было бы слишком поздним: первый кадр отрисовался бы в основном буфере экрана, вызвав видимую вспышку перед переключением. useInsertionEffect срабатывает до layout effects и до того, как браузер (или терминал) нарисовал бы кадр, делая переход бесшовным.
Pool-Based Memory: почему инстансирование важно
Терминал 200x120 содержит 24 000 ячеек. Если бы каждая ячейка была JavaScript-объектом со строками char, style и hyperlink, это было бы 72 000 аллокаций строк на кадр — плюс 24 000 аллокаций объектов для самих ячеек. При 60 fps это 5,76 миллиона аллокаций в секунду. Garbage collector V8 может с этим справиться, но не без пауз, которые проявятся как пропущенные кадры. Паузы GC обычно занимают 1–5 мс, но они непредсказуемы: могут случиться во время streaming token update, вызывая видимый stutter именно тогда, когда пользователь смотрит на вывод.
Claude Code устраняет это полностью с помощью упакованных typed arrays и трёх интернинг-пулов. Результат: ноль per-frame object allocations для буфера ячеек. Единственные аллокации происходят в самих пулах (амортизированно, поскольку большинство символов и стилей интернируются на первом кадре и переиспользуются затем) и в patch strings, порождаемых diff (неизбежно, потому что stdout.write требует string или Buffer).
Layout ячейки использует две 32-битные words на ячейку, хранящиеся в непрерывном Int32Array:
word0: charId (32 бита, индекс в CharPool)
word1: styleId[31:17] | hyperlinkId[16:2] | width[1:0]
Параллельное представление BigInt64Array поверх того же буфера позволяет выполнять bulk operations — очистка строки становится одним вызовом fill() по 64-битным словам вместо обнуления полей по одному.
CharPool интернирует строковые символы в целочисленные ID. У него есть быстрый путь для ASCII: Int32Array на 128 элементов сопоставляет коды символов напрямую с индексами пула, полностью обходя поиск в Map. Многобайтные символы (emoji, CJK ideographs) уходят в Map<string, number>. Индекс 0 всегда означает пробел, индекс 1 всегда означает пустую строку.
export class CharPool {
private strings: string[] = [' ', '']
private ascii: Int32Array = initCharAscii()
intern(char: string): number {
if (char.length === 1) {
const code = char.charCodeAt(0)
if (code < 128) {
const cached = this.ascii[code]!
if (cached !== -1) return cached
const index = this.strings.length
this.strings.push(char)
this.ascii[code] = index
return index
}
}
// Fallback через Map для многобайтных символов
...
}
}
StylePool интернирует массивы ANSI style codes в целочисленные ID. Самая умная деталь: бит 0 каждого ID кодирует, имеет ли стиль видимый эффект на пробелах (background color, inverse, underline). Только foreground-стили получают чётные ID; стили, видимые на пробелах, получают нечётные ID. Это позволяет рендереру пропускать невидимые пробелы одним проверочным битмаском — if (!(styleId & 1) && charId === 0) continue — не заглядывая в определение стиля. Пул также кэширует предварительно сериализованные ANSI transition strings между любыми двумя style IDs, так что переход от «жирный красный» к «тусклый зелёный» — это cached string concatenation, а не diff-and-serialize.
HyperlinkPool интернирует OSC 8 hyperlink URI. Индекс 0 означает отсутствие гиперссылки.
Все три пула разделяются между front и back frames. Это критическое архитектурное решение. Поскольку пулы общие, интернированные ID валидны между кадрами: blit-оптимизация может копировать упакованные слова ячеек напрямую из prevScreen в текущий экран без повторного intern. Diff может сравнивать ID как integers без string lookups. Если бы у каждого кадра были свои пулы, blit пришлось бы пере-интернировать каждую скопированную ячейку (сначала смотреть строку по старому ID, затем интернировать её в новом пуле), и это почти полностью убило бы выигрыш от blit.
Пулы периодически сбрасываются (каждые 5 минут), чтобы предотвратить неограниченный рост во время длинных сессий. Процесс миграции повторно интернирует живые ячейки front frame в свежие пулы.
CellWidth обрабатывает символы двойной ширины с помощью 2-битной классификации:
| Значение | Значение |
|---|---|
| 0 (Narrow) | Обычный одноколоночный символ |
| 1 (Wide) | CJK/emoji head cell, занимает две колонки |
| 2 (SpacerTail) | Вторая колонка wide-символа |
| 3 (SpacerHead) | Маркер продолжения мягкого переноса |
Это хранится в младших 2 битах word1, делая проверки ширины на упакованных ячейках бесплатными — извлечение поля не требуется для common case.
Дополнительные метаданные на уровне ячейки живут в параллельных массивах, а не в packed cells:
noSelect: Uint8Array— флаг, исключающий ячейку из текстового выделения. Используется для UI chrome (рамки, индикаторы), которые не должны попадать в скопированный текстsoftWrap: Int32Array— маркер на уровне строки, указывающий на продолжение word-wrap. Когда пользователь выделяет текст через мягко перенесённую строку, selection logic понимает, что не нужно вставлять newline в точке переносаdamage: Rectangle— bounding box всех записанных ячеек в текущем кадре. Diff проверяет только строки внутри этого прямоугольника, пропуская полностью неизменившиеся области
Эти параллельные массивы не расширяют формат packed cell (что увеличило бы cache pressure во внутреннем цикле diff), но при этом предоставляют метаданные, нужные выделению, копированию и оптимизации.
Screen также предоставляет factory createScreen(), принимающую размеры и ссылки на пулы. Создание экрана обнуляет Int32Array через fill(0n) по view BigInt64Array — один native call, очищающий весь буфер за микросекунды. Это используется при resize (когда нужны новые frame buffers) и при migration пулов (когда старые ячейки экрана пере-интернируются в свежие пулы).
Компонент REPL
REPL (REPL.tsx) — это примерно 5 000 строк. Это самый большой одиночный компонент в codebase, и на то есть причина: он дирижёр всего интерактивного опыта. Всё проходит через него.
Компонент организован примерно в девять секций:
- Imports (~100 строк) — подтягивает bootstrap state, команды, историю, hooks, компоненты, keybindings, отслеживание стоимости, уведомления, поддержку swarm/team и voice integration
- Feature-flagged imports — условная загрузка voice integration, proactive mode, brief tool и coordinator agent через guards
feature()сrequire() - Управление состоянием — обширные вызовы
useState, покрывающие сообщения, режим ввода, ожидающие разрешения, диалоги, пороги стоимости, состояние сессии, состояние инструментов и состояние агента - QueryGuard — управляет жизненным циклом активного API-вызова, не позволяя параллельным запросам наступать друг на друга
- Обработка сообщений — обрабатывает входящие сообщения из query loop, нормализует порядок, управляет streaming state
- Флоу разрешений инструментов — координирует запросы разрешений между блоками использования инструментов и диалогом PermissionRequest
- Управление сессией — resume, переключение, экспорт разговоров
- Настройка keybinding — подключает провайдеры keybinding:
KeybindingSetup,GlobalKeybindingHandlers,CommandKeybindingHandlers - Render tree — собирает финальный UI из всего перечисленного
Его render tree собирает полный интерфейс в fullscreen-режиме:
OffscreenFreeze — это оптимизация производительности, специфичная для терминального рендеринга. Когда сообщение уходит выше viewport, его React-элемент кэшируется, а поддерево замораживается. Это предотвращает timer-based updates (спиннеры, счётчики прошедшего времени) в off-screen сообщениях от запуска terminal resets. Без этого крутящийся индикатор в сообщении 3 вызывал бы полный repaint, хотя пользователь смотрит на сообщение 47.
Компонент компилируется React Compiler’ом повсеместно. Вместо ручного useMemo и useCallback компилятор вставляет memoization на уровне выражений с помощью массивов слотов:
const $ = _c(14); // 14 слотов memoization
let t0;
if ($[0] !== dep1 || $[1] !== dep2) {
t0 = expensiveComputation(dep1, dep2);
$[0] = dep1; $[1] = dep2; $[2] = t0;
} else {
t0 = $[2];
}
Этот паттерн встречается в каждом компоненте codebase. Он даёт более тонкую гранулярность, чем useMemo (который memoize на уровне хука) — отдельные выражения внутри render function получают собственное отслеживание зависимостей и кэш. Для компонента в 5 000 строк вроде REPL это убирает сотни потенциально лишних пересчётов на каждый рендер.
Выделение и подсветка поиска
Выделение текста и подсветка поиска работают как overlay поверх buffer экрана, применяясь после основного рендера, но до diff.
Выделение текста работает только в alt-screen. Ink instance хранит SelectionState, отслеживающее anchor и focus points, режим перетаскивания (character/word/line) и captured rows, которые ушли за пределы экрана. Когда пользователь кликает и тащит мышью, selection handler обновляет эти координаты. Во время onRender applySelectionOverlay проходит по затронутым строкам и изменяет style IDs ячеек на месте с помощью StylePool.withSelectionBg(), который возвращает новый style ID с добавленным inverse video. Эта прямая мутация буфера экрана и есть причина существования флага prevFrameContaminated — буфер front frame был изменён overlay, поэтому следующий кадр не может доверять ему для blit-оптимизации и должен делать full-damage diff.
Mouse tracking использует режим SGR 1003, который сообщает клики, drag и движение с координатами колонка/строка. Компонент App реализует обнаружение multi-click: double-click выбирает слово, triple-click выбирает строку. Детекция использует timeout 500 мс и допуск на перемещение в 1 ячейку (мышь может сместиться на одну ячейку между кликами, не сбрасывая multi-click counter). Клики по гиперссылкам намеренно откладываются этим timeout — двойной клик по ссылке выбирает слово вместо открытия браузера, что соответствует поведению, ожидаемому от текстовых редакторов.
Механизм recovery при потерянном отпускании кнопки обрабатывает случай, когда пользователь начинает drag внутри терминала, уводит мышь за пределы окна и отпускает. Терминал сообщает press и drag, но не release (который произошёл вне окна). Без recovery выделение застряло бы в режиме drag навсегда. Recovery работает, обнаруживая mouse motion events без нажатых кнопок — если мы в состоянии drag и получаем motion без кнопок, мы делаем вывод, что кнопка была отпущена вне окна, и завершаем выделение.
Подсветка поиска имеет два механизма, работающих параллельно. Scan-based путь (applySearchHighlight) проходит по видимым ячейкам в поиске query string и применяет SGR inverse styling. Position-based путь использует заранее вычисленные MatchPosition[] из scanElementSubtree(), сохранённые относительно сообщения, и применяет их в известных смещениях с «текущим совпадением» в жёлтой подсветке, используя stacked ANSI codes (inverse + жёлтый foreground + bold + underline). Жёлтый foreground в сочетании с inverse превращается в жёлтый фон — терминал меняет местами fg/bg, когда inverse активен. Underline служит запасным маркером видимости для тем, где жёлтый конфликтует с существующими цветами фона.
Объявление курсора решает тонкую проблему. Эмуляторы терминала рисуют preedit text IME (Input Method Editor) в физической позиции курсора. Пользователям CJK, вводящим иероглифы, нужен курсор не внизу экрана, где терминал обычно паркует его, а в позиции caret поля ввода. Хук useDeclaredCursor позволяет компоненту объявить, где курсор должен находиться после каждого кадра. Класс Ink читает позицию объявленного узла из nodeCache, переводит её в экранные координаты и отправляет escape-последовательности перемещения курсора после diff. Screen readers и magnifiers тоже отслеживают физический курсор, так что этот механизм помогает и доступности, и CJK-вводу.
В режиме main-screen объявленная позиция курсора отслеживается отдельно от frame.cursor (который должен оставаться внизу контента ради инвариантов относительных перемещений log-update). В режиме alt-screen проблема проще: каждый кадр начинается с CSI H (cursor home), поэтому объявленный курсор — это просто абсолютная позиция, отправляемая в конце кадра.
Streaming Markdown
Рендеринг вывода LLM — самая требовательная задача, с которой сталкивается terminal UI. Токены приходят по одному, 10–50 в секунду, и каждый меняет содержимое сообщения, которое может содержать code blocks, списки, жирный текст и inline code. Наивный подход — перепарсивать всё сообщение на каждый токен — в масштабе был бы катастрофой.
Claude Code использует три оптимизации:
Кэширование токенов. LRU-кэш уровня модуля (500 записей) хранит результаты marked.lexer(), ключом выступает content hash. Кэш переживает React unmount/remount во время виртуальной прокрутки. Когда пользователь возвращается к ранее видимому сообщению, markdown-токены берутся из кэша, а не парсятся заново.
Fast-path detection. hasMarkdownSyntax() проверяет первые 500 символов на markdown markers с помощью одного regex. Если синтаксиса нет, он создаёт один paragraph-token напрямую, минуя полный GFM parser. Это экономит примерно 3 мс на рендер для plain-text сообщений — а это важно, когда вы рендерите 60 кадров в секунду.
Ленивая syntax highlighting. Подсветка code block загружается через React Suspense. Компонент MarkdownBody сразу рендерится с highlight={null} как fallback, а затем асинхронно получает cli-highlight instance. Пользователь видит код немедленно (без стилей), а затем через кадр-два он «вспыхивает» цветом.
Стриминговый случай добавляет нюанс. Когда токены приходят от модели, markdown-контент растёт инкрементально. Перепарсивать весь контент на каждый токен означало бы O(n^2) за время сообщения. Fast-path detection помогает — большая часть стримингового контента это plain text paragraphs, которые полностью обходят parser, — но для сообщений с code blocks и списками реальную оптимизацию даёт LRU-кэш. Ключ кэша — content hash, поэтому когда приходит 10 токенов и меняется только последний абзац, используется cached parse result для неизменившегося префикса. Markdown-рендерер перепарсивает только изменившийся хвост.
Компонент StreamingMarkdown отличается от статического Markdown. Он обрабатывает случай, когда контент всё ещё генерируется: незакрытые code fences ( ``` без закрывающего fence), частичные маркеры bold и обрезанные элементы списка. Потоковая версия более снисходительна к парсингу — она не падает на незакрытом синтаксисе, потому что закрывающий синтаксис ещё не пришёл. Когда сообщение завершает поток, компонент переходит на статический Markdown renderer, который применяет полный GFM parsing со строгой проверкой синтаксиса.
Syntax highlighting для code blocks — самая дорогая per-element операция в rendering pipeline. Code block на 100 строк может занимать 50–100 мс для подсветки с cli-highlight. Загрузка самой библиотеки подсветки занимает 200–300 мс (она упаковывает grammar definitions для десятков языков). Оба этих cost скрыты за React Suspense: code block сразу рендерится как plain text, библиотека подсветки загружается асинхронно, и когда она готова, code block перерисовывается с цветами. Пользователь видит код мгновенно и цвета чуть позже — гораздо лучший опыт, чем пустой кадр на 300 мс, пока библиотека загружается.
Примените это: эффективный рендеринг потокового вывода
Терминальный rendering pipeline — это кейс-стади по устранению работы. Дизайн определяют три принципа:
Интернируйте всё. Если у вас есть значение, встречающееся в тысячах ячеек — стиль, символ, URL — храните его один раз и обращайтесь к нему по целочисленному ID. Сравнение целых чисел — одна CPU-операция. Сравнение строк — цикл. Когда ваш inner loop выполняется 24 000 раз на кадр при 60 fps, разница между === для чисел и === для строк — это разница между плавной прокруткой и заметной задержкой.
Делайте diff на правильном уровне. Diff на уровне ячеек звучит дорого — 24 000 сравнений на кадр. Но это два сравнения целых чисел на ячейку (упакованные words), и в стабильном кадре diff обычно выходит из большинства строк уже после проверки первой ячейки. Альтернатива — перерендерить весь экран и записать его в stdout — дала бы 100 КБ+ ANSI escape-последовательностей на кадр. Diff обычно выдаёт меньше 1 КБ.
Отделяйте hot path от React. События прокрутки приходят с частотой mouse-input (потенциально сотни в секунду). Пропускать каждое через reconciler React — state update, reconciliation, commit, layout, render — добавляет 5–10 мс latency на событие. Изменяя DOM-узлы напрямую и планируя рендер через microtask, scroll path держится ниже 1 мс. React участвует только в финальном paint, где он всё равно бы выполнялся.
Эти принципы применимы к любой системе потокового вывода, не только к терминалам. Если вы строите веб-приложение, рендерящее данные в реальном времени — log viewer, chat client, monitoring dashboard, — те же компромиссы работают. Интернируйте повторяющиеся значения. Делайте diff относительно предыдущего кадра. Выносите hot path за пределы реактивного фреймворка.
Четвёртый принцип, специфичный для долгих сессий: регулярно очищайте накопленное. Пулы Claude Code растут монотонно по мере интернирования новых символов и стилей. За многосеансовую работу пулы могли бы накопить тысячи записей, больше не связанных ни с одной живой ячейкой. Пятиминутный цикл reset ограничивает этот рост: каждые 5 минут создаются свежие пулы, живые ячейки front frame мигрируют (пере-интернируются в новые пулы), а старые пулы становятся мусором. Это стратегия generational collection, применённая на уровне приложения, потому что JavaScript GC не видит семантическую живость записей в пулах.
Решение использовать Int32Array вместо обычных объектов имеет более тонкое преимущество помимо GC pressure: memory locality. Когда diff сравнивает 24 000 ячеек, он идёт по непрерывному typed array. Современные CPU prefetch-предсказывают последовательные обращения к памяти, так что всё сравнение экрана укладывается в L1/L2 cache. Объектная раскладка по ячейкам раскидала бы их по heap, превращая каждое сравнение в cache miss. Разница измерима: на экране 200x120 typed-array diff завершается менее чем за 0,5 мс, тогда как эквивалентный object-based diff занимает 3–5 мс — этого достаточно, чтобы пробить 16 мс frame budget вместе с другими стадиями pipeline.
Пятый принцип применим к любой системе, рендерящей в фиксированную сетку: отслеживайте границы повреждения. Прямоугольник damage на каждом экране хранит bounding box ячеек, записанных во время рендера. Diff обращается к этому прямоугольнику и полностью пропускает строки вне его. Когда стриминговое сообщение занимает нижние 20 строк терминала из 120, diff проверяет 20 строк, а не 120. В сочетании с blit-оптимизацией (которая заполняет damage rectangle только для перерендеренных областей, а не для blitted ones) это означает, что обычный случай — одно сообщение стримится, а остальная беседа статична — затрагивает лишь часть buffer экрана.
Более широкий урок: производительность в rendering system — это не сделать одну операцию быстрой. Это убрать операции совсем. Blit убирает повторный рендер. Damage rectangle убирает diffing. Разделение пулов убирает повторное интернирование. Packed cells убирают аллокации. Каждая оптимизация удаляет целый класс работы, и они перемножаются.
Если назвать числа: худший кадр (всё dirty, без blit, full-screen damage) на терминале 200x120 занимает примерно 12 мс. Лучший кадр (один dirty node, остальное blit, 3-строчный damage rectangle) занимает меньше 1 мс. Система большую часть времени находится в лучшем случае. Стриминг токена вызывает один dirty text node, который помечает своих предков вплоть до контейнера сообщения, обычно охватывающего 10–30 строк экрана. Blit обрабатывает остальные 90–110 строк. Damage rectangle ограничивает diff dirty-областью. Lookup в пулах — integer operations. Стоимость потока одного токена в steady-state в основном определяется Yoga layout (который повторно измеряет dirty text node и его предков) и повторным разбором markdown — а не самим rendering pipeline.