Последний год я почти перестал печатать код руками. Чаще просто диктую задачу агенту в терминале — Claude Code, Codex, Qwen, что под рукой. И в какой-то момент посмотрел на свой здоровенный IDE и понял: он превратился в дорогую рамку вокруг одного-единственного окна — терминала. Все эти панели, индексаторы, плагины придумывались под сценарий “человек сам пишет проект”. А я уже не пишу. Я направляю и проверяю.
Голый терминал, даже в tmux, тоже не спасает, когда проектов несколько. Не видно, какой агент уже закончил, какой завис с вопросом, что он там наменял в файлах и где вообще какой проект. В общем, я психанул и сделал маленький десктопный редактор, в котором поменял приоритеты местами: терминал тут главный, а дерево с кодом — сбоку, как подсказка, и прячется одной кнопкой.
Стек максимально скучный: Electron + xterm.js + node-pty + CodeMirror 6, фронт собирается esbuild. Всё интересное, как обычно, спряталось в деталях — про них и расскажу.
Сначала про архитектуру, чтобы не прирасти к Electron
Главный процесс (main.js) я держу нарочно тонким: диалоги, жизненный цикл PTY, файловые операции, окно, буфер обмена. И всё. Никакой логики UI там нет — она вся в рендерере и бандлится esbuild, а единственный мост наружу это preload.js с contextIsolation: true и выключенным nodeIntegration. Рендерер в Node напрямую не лезет, только через window.lite.*.
Зачем такая дисциплина? Если завтра захочется уехать с Electron на что-нибудь полегче (поглядываю на Tauri), переписывать придётся только тонкий бэкенд. Фронт и вся продуктовая логика переедут почти как есть. Пока “main ничего не знает про UI” — оно того стоит.
Самое мясо: как понять, что вообще делает агент
Вот это ядро всего редактора. У каждого проекта свой индикатор, три состояния:
-
🔵 busy — агент работает (крутится спиннер);
-
🟡 waiting — тихо, но ждёт твоего ответа (вопрос или разрешение);
-
🟢 quiet — голый шелл, заняться нечем.
Первое, что приходит в голову — парсить вывод и искать “(y/n)” или приглашение. Не делайте так. Оно хрупкое до невозможности: у каждого агента свой формат, всё на разных языках, промпты у всех свои. Хотелось, чтобы работало с любым агентом, а не подстраивалось под каждого по тексту.
В итоге сработало вот что: смотреть не на текст, а на состояние процессов в псевдотерминале. На Linux это бесплатно даёт /proc.
Логика простая. На каждый чих вывода PTY включаем busy и взводим таймер. Вывод стих (по умолчанию ставлю 1200 мс тишины) — идём спрашивать у главного процесса, что там в TTY происходит:
// renderer: вывод стих - спрашиваем ОС, кто в форграундеasync function settleProject(id) { const kind = await lite.pty.foregroundState(id); // 'shell' | 'running' | 'waiting' | null if (kind === 'running') { /* программа считает молча - держим спиннер, поллим дальше */ } else if (kind === 'waiting') setState(id, 'waiting'); // жив и ждёт ввода else setState(id, 'quiet'); // вернулись к голому шеллу}
Вся соль — в foregroundState на стороне main. Берём активную (foreground) группу процессов терминала — ту, что получает ввод с клавиатуры, — и читаем /proc/<pid>/stat:
// упрощённо: чей это форграунд и в каком он состоянииfunction foregroundKind(ptyPid) { const tpgid = readStat(ptyPid).tpgid; // foreground process group TTY const leader = readStat(tpgid); // лидер этой группы if (isShell(leader.comm)) return 'shell'; // голый bash/zsh → нам тут делать нечего // в группе есть процесс в состоянии R (runs) или D (uninterruptible) → реально работает if (groupHasRunnable(tpgid)) return 'running'; return 'waiting'; // все спят (S) → программа жива и ждёт ввода}
И вот это красиво: оно одинаково работает для Claude Code, Codex, Qwen, Kimi, npm test, ssh, psql — потому что мы смотрим на состояние процесса, а не на его буквы. “Янтарный” честно значит “открытая программа жива и ждёт тебя”. А закончил агент ход или задал вопрос — по состоянию процесса не различить, да и не надо: решать всё равно тебе.
Грабли №1: BEL, который не BEL
Когда /proc недоступен (не-Linux или просто не прочиталось), приходится откатываться на эвристику. И тут классика жанра. Терминальный “звонок” \x07 (BEL) — вроде бы отличный сигнал “посмотри на меня”, агенты его шлют осознанно. Но! bash/zsh/Claude дёргают BEL на каждом приглашении — он там как терминатор последовательности заголовка окна, ESC ] 0 ; title BEL (это OSC). Посчитаешь каждый такой BEL за звонок — и индикатор будет трястись без остановки.
Лечится тем, что сперва вырезаем OSC, а уже в остатке ищем “настоящий” звонок:
const OSC = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g; // ESC ] ... BEL (заголовок окна)const hasRealBell = (s) => s.replace(OSC, '').includes('\x07');
Грабли №2: собственное эхо набора
Ещё веселее. Когда ты сам печатаешь в терминал, символы возвращаются эхом — это тоже вывод PTY. Наивный детектор честно считает это за “агент работает” и моргает на каждую нажатую клавишу. Поэтому короткие порции вывода (до 8 байт) сразу после нажатия (в пределах 250 мс) я за активность не считаю. Плюс счётчик activitySeq: если пока мы ждали ответа от /proc, прилетел новый вывод — старое решение выкидываем.
Мелочи, да. Но без них “умный индикатор” превращается в нервный тик.
node-pty, ConPTY и сборка под Windows прямо с Linux
node-pty — нативный модуль, собирается под ABI конкретного Electron (через electron-rebuild). Первая засада сразу: плейном node его не загрузить, тестовые скрипты гоняю через ELECTRON_RUN_AS_NODE=1 ./node_modules/.bin/electron. Один раз об это споткнёшься — запомнишь надолго.
Дальше кросс-платформа. На Linux PTY живёт через /dev/pts, на Windows — через ConPTY. И что приятно удивило: Windows-сборку реально собрать прямо с Linux, без всякого Wine. Надо только знать пару вещей в конфиге electron-builder:
-
win.signAndEditExecutable: false— иначе для патча.exeпонадобится Wine; так мы просто пакуем portable-zip и не паримся; -
asarUnpackдля node-pty, а на Windows ещё иasar: false— иначе ConPTY не находит свойconpty.nodeи хелперы внутри asar, и терминал молча виснет (искал причину неприлично долго); -
готовые win32-prebuild’ы node-pty работают: загрузчик ловит ошибку linux-бинаря и падает в
prebuilds/win32-x64.
Но давайте честно: “собирается с Linux” ещё не значит “работает на Windows”. Нативный терминал надо собирать и щупать на самой винде. Поэтому релизы едут через GitHub Actions, матрицей: ubuntu-latest (--linux deb) плюс windows-latest (--win zip).
Грабли №3: —publish never
Отдельный поклон electron-builder. Соберёшь по git-тегу без --publish never — и он сам полезет публиковать релиз, а потом упадёт с GitHub Personal Access Token is not set... GH_TOKEN. Поэтому electron-builder у меня только собирает, а публикует (с описанием и prerelease: true) отдельный шаг softprops/action-gh-release. И ещё момент: AppImage на ubuntu-раннере у меня стабильно падал, так что оставил только deb и не воюю.
Несколько терминалов на проект
Поначалу был “один PTY на проект”. И почти сразу стало тесно: хочется агента в одной вкладке, dev-сервер в другой, разовую команду прогнать в третьей. Завёл понятие сессии: terms теперь keyed по sessionId (<projId>::t<N>), а tabsByProj хранит порядок вкладок и активную. PTY как жили в main по этому id, так и живут — бэкенд почти не трогал, что приятно.
Тонкость с индикатором: вкладок у проекта теперь несколько, поэтому точка на карточке — это агрегат (busy > waiting > quiet), а у каждой вкладки свой статус отдельно.
И ещё деталь, которая мне самому нравится. PTY — это живой процесс ОС, перезапуск приложения он не переживёт, воскресить честно нельзя. Но имена вкладок я сохраняю между запусками. Открываешь проект на следующий день — а тебя встречают те же “Терминал 1 / dev server”, пусть и пустые. Вроде ерунда, а ощущается как “всё на месте”.
Темы как контракт токенов
Захотелось несколько тем оформления. Первый порыв — накидать body[data-theme=…] и перекрашивать цвета точечно. Так делать не надо, проверено: каждый новый компонент потом надо не забыть перекрасить во всех темах сразу, и однажды ты забудешь.
Сделал через контракт токенов. В :root лежит полный набор переменных — поверхности, линии, текст, акцент, тени, радиусы, — и ни одно правило компонента не хардкодит цвет, только var(--token). Тогда тема это просто один самодостаточный блок, который переопределяет весь набор:
:root { /* тема по умолчанию: все токены */ --bg:…; --surface:…; --border:…; --accent:…; }body[data-theme="glass"] { --bg:…; --surface:rgba(255,255,255,.06); /* + backdrop-filter */ }body[data-theme="gruvbox"] { --bg:…; --accent:#fabd2f; /* … */ }
Добавить тему = скопировать блок и проставить значения. Шесть тем (включая неоморфизм с “выдавленными” тенями и стекло с блюром) уживаются без единого if в JS. Терминал перекрашивается тем же набором — фон и курсор xterm берутся из той же палитры.
Мелочь, на которой я реально подгорел: Ctrl+V в русской раскладке
Классика, на которой спотыкается половина Electron-приложений. Ловил copy/paste вот так:
if (e.ctrlKey && e.key === 'v') paste(); // ← работает только в EN-раскладке
А я полдня сижу в русской раскладке. И e.key при Ctrl+V в ней — это 'м', а никакой не 'v'. Условие не срабатывает, человек лезет в контекстное меню и тихо начинает тебя ненавидеть. Правильно матчить по физической клавише:
if (e.ctrlKey && e.code === 'KeyV') paste(); // ← работает в любой раскладке
e.code от раскладки не зависит — ровно поэтому в нормальных IDE копипаст “просто работает”. Тем же приёмом чиним Ctrl+C, Ctrl+F и хоткеи вкладок. Полчаса работы, а бесило знатно.
Чтобы не терять данные: атомарная запись и логи
Раз уж это инструмент на каждый день, две вещи про надёжность — потому что терять чужие данные стыдно.
Первое — атомарная запись стора. Список проектов, настройки, заметки лежат в JSON. Если процесс умрёт (или питание моргнёт) ровно в момент записи projects.json, при старте JSON.parse подавится — и весь список проектов превратится в тыкву. Поэтому пишу через временный файл и rename (он атомарен на одной ФС):
function atomicWriteSync(file, data) { const tmp = file + '.tmp'; fs.writeFileSync(tmp, data); fs.renameSync(tmp, file); // либо старый файл целый, либо новый целиком, середины не бывает}
Второе — краш-устойчивые логи. Лог главного процесса пишется appendFileSync, чтобы последняя строка перед падением не потерялась. Плюс ловлю uncaughtException / unhandledRejection и события render-process-gone / child-process-gone. Раньше окно иногда просто молча закрывалось, и это была загадка. Теперь это строчка в логе с reason и exitCode.
А что честно не так
Это alpha, и продавать я тут ничего не собираюсь, так что вот честный список болячек:
-
Детект состояния агента точен только на Linux. Он стоит на
/proc. На Windows/procнет, ConPTY такого не отдаёт, и там работает фолбэк-эвристика (тот самый BEL плюс узкий regex) — это заметно хуже. Нормальное кросс-платформенное решение — shell integration через OSC 133, но это отдельная большая история, до неё руки ещё не дошли. -
Терминалы не переживают перезапуск (только имена вкладок, как писал выше).
-
Вивер открывает один файл за раз, и большие файлы не тянет.
Стек и где посмотреть
Итого: Electron + xterm.js + node-pty + CodeMirror 6, esbuild, без тяжёлых фреймворков. И самым ценным оказалось не “написать ещё один редактор” (кому он нужен), а несколько узких задачек: детект состояния через /proc без привязки к конкретному агенту, кросс-сборка Electron под Windows с Linux и упрямая дисциплина “main ничего не знает про UI”.
Если хочется поковыряться или просто глянуть код — он открыт под Apache-2.0: github.com/DanielLetto2020/LiteEditorAI. И мне правда интересны ваши грабли в похожих задачах — особенно у кого как сделан кросс-платформенный детект состояния процессов. Делитесь, забирайте идеи.
ссылка на оригинал статьи https://habr.com/ru/articles/1041064/