А что, если бы ваш терминал был браузером? TermDOM рендерит настоящий DOM-документ — с каскадом CSS, flexbox, формами и веб-компонентами — прямо на экран терминала. Разбираемся, как это работает и что можно построить уже сегодня.
Долгое время создание терминального интерфейса означало изучение нового мира: деревья виджетов, вызовы раскладки, таблицы клавиш. Ink принёс React-компоненты в терминал. blessed дал классический набор виджетов. Но каждый подход заставлял думать в терминальных абстракциях, а не в тех веб-технологиях, которые вы уже знаете.
TermDOM идёт обратным путём: он реализует конвейер рендеринга браузера
на сетке символьных ячеек вместо пикселей. В результате библиотека рендерит настоящий
DOM-документ — тот самый, который вы меняете через document.querySelector()
и innerHTML, — прямо в терминал. Node Weekly № 637 (13 августа 2026) вынес её
в заголовок выпуска, а репозиторий активно обновлялся ещё 16 августа 2026 года, так что
это свежий взгляд на быстро меняющийся проект.
В статье я разберу, что такое TermDOM на самом деле, как устроен его конвейер рендеринга, как шаг за шагом собрать небольшой TUI, где он находится рядом с Ink, blessed и clack, и что говорит матрица совместимости об ограничениях сегодня.
TermDOM — это JavaScript-библиотека, которая отображает HTML и CSS в терминале. Она рисует реальные DOM-узлы в терминальном выводе и перерисовывает экран при их изменении, поэтому терминальные интерфейсы и интерактивные CLI можно писать на чистом JavaScript или с любым фронтенд-фреймворком. В README это сформулировано одной строкой: «Напишите веб-страницу — получите TUI».
Пакет в npm называется @b9g/termdom (версия 0.1.3 на момент написания статьи,
распространяется как пре-релиз):
npm install @b9g/termdom
Минимальная программа выглядит так:
import {TermDOM} from "@b9g/termdom";
const term = new TermDOM();
term.attach();
// document — настоящий DOM-документ.
const {document} = term;
document.body.innerHTML = `
<style>
.card { border: 1px solid #5fafff; padding: 0 1ch; width: 36ch; }
.title { color: #5fafff; font-weight: bold; }
</style>
<div class="card">
<div class="title">Привет, терминал</div>
<div id="status">Готово</div>
</div>
`;
Вот и всё. Библиотека перехватывает терминал, разбирает разметку, применяет стили и рисует результат. Каждый символ на экране — это DOM-элемент; каждое изменение DOM отражается автоматически. Здесь нет вызова рендера, нет ручного сравнения виртуального DOM — изменения отрисовываются сами, как в браузере.
Одна ячейка — 1ch в ширину и 1px в высоту. Каждый блок ложится
на целые ячейки. Вы пишете веб-страницу, TermDOM превращает её в TUI.
TermDOM реализует конвейер рендеринга браузера на сетке символьных ячеек вместо пикселей.
CSS-длины отображаются на сетку — 1px это одна строка, 1ch это
один столбец — поэтому блочная модель, flexbox и таблицы раскладываются в целые ячейки.
Вот полный цикл, как его описывает README:
<style> и атрибутов style каскадируется и наследуется, как в браузере, а затем переводится в ANSI-последовательности для цвета и оформления текста.
Именно перерисовка по мутациям делает библиотеку похожей на браузер.
MutationObserver следит за документом; когда что-то меняется, движок
пересчитывает стили и раскладку затронутых поддеревьев, перерисовывает и диффает.
Привычные DOM-утилиты подключены к движку раскладки и вьюпорту:
document.querySelector(), MutationObserver,
ResizeObserver и Element.getBoundingClientRect() следуют
стандартам браузера.
Упрощённая модель цикла кадров:
while (running) {
// 1. Были ли мутации DOM? Их поставили в очередь колбэки MutationObserver.
const changes = drainMutationQueue();
if (changes.length > 0) {
// 2. Пересчитываем стили затронутых поддеревьев (каскад + наследование).
for (const node of changes) {
recomputeStyle(node); // CSS -> итоговые цвета и оформление
layout(node); // блочная модель, flexbox, таблицы -> ячейки
}
// 3. Рисуем буфер ячеек и сравниваем с прошлым кадром.
const buffer = paint(document.body);
const patch = diff(previousBuffer, buffer);
// 4. Пишем в stdout только разницу как ANSI-последовательности.
if (patch.length > 0) writeToStdout(patch);
previousBuffer = buffer;
}
}
Так как вывод — это дифф, спиннер, меняющий один <span> за кадр,
обходится в несколько escape-последовательностей, а не в полную перерисовку. А поскольку
ввод декодируется из stdin, ваши слушатели keydown получают обычные DOM-события.
Канонический первый пример — карточка прогресса «Установка». Она использует
setInterval, чтобы менять три спана, а TermDOM отрисовывает каждую мутацию:
import {TermDOM} from "@b9g/termdom";
const term = new TermDOM();
term.attach();
const {document} = term;
document.body.innerHTML = `
<style>
.card { border: 1px solid #5fafff; padding: 0 1ch; width: 36ch; }
.title { color: #5fafff; font-weight: bold; }
.done { color: green; }
.rest { color: #444; }
.pct { color: #888; }
</style>
<div class="card">
<div class="title">Установка</div>
<div>
<span class="done" id="done"></span><span class="rest" id="rest"></span>
<span class="pct" id="pct"></span>
</div>
</div>
`;
// TermDOM наблюдает за мутациями и перерисовывает экран сам.
let n = 0;
setInterval(() => {
n = (n + 1) % 101;
const cells = Math.round(n / 4);
document.getElementById("done").textContent = "█".repeat(cells);
document.getElementById("rest").textContent = "░".repeat(25 - cells);
document.getElementById("pct").textContent = String(n).padStart(3) + "%";
}, 50);
Полоса рисуется блочными символами, проценты выравниваются по правому краю через
padStart, а цвета берутся из каскада CSS. Обратите внимание, чего здесь нет:
никакого term.render(), ручного позиционирования курсора или склейки ANSI-строк.
Обновление DOM и есть рендер.
Поскольку документ и события следуют стандартам браузера, фронтенд-фреймворки работают
почти без подготовки. В руководстве по началу работы глобальные объекты, которые ожидает
каждый фреймворк, проверены на React 19 и Vue 3.5: React монтируется с одними
document и window, а Vue дополнительно читает DOM-конструкторы
для проверок instanceof и захватывает document при загрузке модуля — поэтому
глобальные объекты фреймворка нужно установить до динамического импорта. Там же показано,
что библиотека одинаково работает на Node, Bun и Deno: примеры импортируют
@b9g/termdom по имени пакета.
Флагманский пример проекта доказывает, как далеко заходит подход веб-платформы: официальный TodoMVC запускается с неизменённой логикой компонентов — поменяли только таблицу стилей. Та же разметка, тот же JavaScript, терминальная таблица стилей — и приложение становится TUI. Это сильнейший аргумент в пользу дизайна TermDOM: существующий веб-код можно перенацелить на терминал.
Вот минимальный интерактивный список с навигацией с клавиатуры на чистых DOM-API:
const {document} = term;
document.body.innerHTML = `
<style>
.list { border: 1px solid #888; width: 40ch; padding: 1ch; }
.item { padding: 0 1ch; }
.item.selected { background-color: #5fafff; color: #000; }
</style>
<div class="list" id="list"></div>
`;
const items = ["Собрать TUI", "Задеплоить", "Отпраздновать"];
const list = document.getElementById("list");
let selected = 0;
function render() {
list.innerHTML = "";
items.forEach((text, i) => {
const div = document.createElement("div");
div.className = "item" + (i === selected ? " selected" : "");
div.textContent = text;
list.appendChild(div);
});
}
document.addEventListener("keydown", (ev) => {
if (ev.key === "j") selected = (selected + 1) % items.length;
if (ev.key === "k") selected = (selected - 1 + items.length) % items.length;
render();
});
render();
События клавиатуры приходят как настоящие DOM-события на document, классы
переключаются обычными строками, а выбранная строка получает цвет фона из CSS. Тот же код
работает в браузере с другой таблицей стилей — в этом и смысл.
В каталоге примеров репозитория виден весь диапазон: просмотрщик Markdown с постраничной навигацией, когда документ выше терминала, стриминговый чат-клиент для LLM с историей и полем ввода, нечёткий поиск файлов, погодный виджет с эмодзи и flexbox-карточками дней, меню-бар из декларативных поповеров и полностью играбельный «Клондайк» с детерминированными раскладами.
Документы выше терминала прокручиваются через window.scrollTo() и
element.scrollIntoView(). В примере файлового браузера в стиле NERDTree
перемещение выделения вызывает scrollIntoView(), чтобы двигать «камеру»:
document.addEventListener("keydown", (ev) => {
if (ev.key === "j") select(selected + 1);
if (ev.key === "Enter") expand(rows()[selected]);
});
// Держим выбранную строку в зоне видимости.
rows()[selected].scrollIntoView();
События клавиатуры, мыши, фокуса и вставки срабатывают на элементах, документе и окне —
они извлекаются из STDIN. Это одна из причин, почему фреймворки работают: они вешают
слушателей на document и window ровно так же, как в браузере,
а TermDOM предоставляет события. Поддержка мыши включает выделение перетаскиванием и
кликабельные строки; вставка позволяет полю чата принимать текст из буфера обмена.
<input>, <textarea>, <select>,
чекбоксы и радиокнопки имеют поведение по умолчанию и «нативные» для терминала стили,
которые можно менять обычным CSS. Работают навигация по Tab и стили :focus.
Каретка — это настоящий курсор терминала, поэтому методы ввода CJK работают прямо в поле,
с измерением в ячейках:
document.body.innerHTML = `
<style>
.field { margin-bottom: 1ch; }
.label { color: #5fafff; }
input { border: 1px solid #888; }
input:focus { border-color: #5fafff; }
</style>
<div class="field">
<div class="label">Имя</div><input id="name">
</div>
<div class="field">
<div class="label">Приоритет</div>
<select id="priority">
<option>низкий</option><option>высокий</option>
</select>
</div>
`;
document.getElementById("name").addEventListener("input", updatePreview);
customElements.define(), attachShadow(), <slot>,
:host и изолированные стили работают как в браузере. Встроенные элементы форм
сами являются теневыми деревьями. Значит, библиотеки компонентов, написанные для веба, а не
для терминала, могут рендериться внутри TermDOM:
class StatusBadge extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({mode: "open"});
shadow.innerHTML = `
<style>
:host { border: 1px solid #5fafff; padding: 0 1ch; }
.ok { color: green; }
</style>
<span class="ok"><slot></slot></span>
`;
}
}
customElements.define("status-badge", StatusBadge);
document.body.innerHTML = `
<status-badge>задеплоено</status-badge>
`;
CJK, эмодзи и комбинируемые символы занимают правильную ширину; иврит и арабский рендерятся
в визуальном порядке с контекстным формированием знаков, а каретка движется по графемам.
Можно выделять текст перетаскиванием и стилизовать выделение через ::selection.
Element.requestFullscreen() рисует элемент на альтернативном экране, а выход
восстанавливает оболочку и её скроллбек — классическое поведение полноэкранных приложений.
Как выбрать? Таблица сравнивает четыре подхода, к которым чаще всего обращаются:
| TermDOM | Ink | blessed | clack | |
|---|---|---|---|---|
| Модель | Настоящий DOM + CSS | React-компоненты | Дерево виджетов | Промпты |
| Раскладка | Блочная модель, flexbox, таблицы | Yoga (flexbox) | Ручная absolute/grid | Готовая |
| Стили | Каскад CSS + наследование | Инлайн-стили в JSX | Объекты стилей на виджет | Темы |
| Фреймворк | Любой (vanilla, React, Vue…) | Только React | Нет | Нет |
| Веб-компоненты | Да (shadow DOM) | Нет | Нет | Нет |
| Переиспользование веб-кода | Да — замена таблицы стилей | React-компоненты | Нет | Нет |
| Зрелость | Молодой, пре-релиз | Зрелый | Зрелый (поддержка) | Зрелый |
Выбирайте TermDOM, когда хотите переиспользовать веб-навыки и веб-компоненты или когда ваша команда уже мыслит в HTML и CSS. Выбирайте Ink, если живёте в экосистеме React и нужна проверенная библиотека с компонентной моделью. Выбирайте blessed для классических терминальных приложений на виджетах, которым нужен тонкий контроль и не страшен низкоуровневый API. Выбирайте clack, когда нужен только набор аккуратных промптов для CLI.
TermDOM молод. Пакет в npm — пре-релиз (0.1.3), в репозитории на момент написания статьи было около 133 звёзд, и API ещё может меняться. Проект честен в этом: матрица совместимости собирается набором проб, которые применяют каждый DOM-API, селектор и CSS-свойство к реальному документу и записывают, изменился ли вывод. Текущие цифры: 103 функции поддерживаются, 108 проверены и не работают, 301 CSS-свойство ещё не проверено.
Что это значит на практике:
grid-template-columns и остальные проверены и не поддерживаются. Инструменты раскладки — flexbox и таблицы.border-block, margin-inline, inset-block и другие проверены, но не дают эффекта; используйте физические свойства.box-sizing, float и clear — движок раскладки следует только блочной, flex- и табличной моделям.font-family, font-size и line-height не действуют; у терминала один шрифт, и текст измеряется в ячейках.В README также есть явный раздел «Что не поддерживается», так что вы встречаетесь с ограничениями до того, как встретите баг. Для экспериментов, внутренних инструментов разработчика и всего, где DOM браузерного уровня ценнее полировки виджетов, этот компромисс очень привлекателен.
Если сравнивать это с другими способами рендеринга HTML вне окна браузера, мой обзор HTML-in-Canvas API описывает противоположное направление: отрисовку настоящих DOM-элементов на canvas в пикселях, а не в ячейках.
Терминальные интерфейсы на веб-стандартах — по-настоящему свежее направление, и TermDOM — самый буквальный его пример: пишете HTML и CSS, получаете TUI. Как full-stack разработчик, я создаю и консольные инструменты, и веб-приложения, и всегда выбираю ту модель рендеринга, которая делает продукт проще всего поддерживать.
Если у вас есть идея продукта — терминального или браузерного — и нужен взгляд со стороны на правильную архитектуру, напишите мне. Первая консультация бесплатная, без давления и навязывания.
Есть проект? Помогу выбрать правильную архитектуру — веб-приложение, CLI-инструмент или что-то среднее — и сделать её правильно. Первая консультация бесплатно.