Редактор, в котором главный — терминал: как я делал лёгкую IDE под эпоху ИИ-агентов
- суббота, 30 мая 2026 г. в 00:00:11
Последний год я почти перестал печатать код руками. Чаще просто диктую задачу агенту в терминале - Claude Code, Codex, Qwen, что под рукой. И в какой-то момент посмотрел на свой здоровенный IDE и понял: он превратился в дорогую рамку вокруг одного-единственного окна - терминала. Все эти панели, индексаторы, плагины придумывались под сценарий “человек сам пишет проект”. А я уже не пишу. Я направляю и проверяю.
Голый терминал, даже в tmux, тоже не спасает, когда проектов несколько. Не видно, какой агент уже закончил, какой завис с вопросом, что он там наменял в файлах и где вообще какой проект. В общем, я психанул и сделал маленький десктопный редактор, в котором поменял приоритеты местами: терминал тут главный, а дерево с кодом - сбоку, как подсказка, и прячется одной кнопкой.
Стек максимально скучный: Electron + xterm.js + node-pty + CodeMirror 6, фронт собирается esbuild. Всё интересное, как обычно, спряталось в деталях - про них и расскажу.
Главный процесс (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 - потому что мы смотрим на состояние процесса, а не на его буквы. “Янтарный” честно значит “открытая программа жива и ждёт тебя”. А закончил агент ход или задал вопрос - по состоянию процесса не различить, да и не надо: решать всё равно тебе.
Когда /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');
Ещё веселее. Когда ты сам печатаешь в терминал, символы возвращаются эхом - это тоже вывод PTY. Наивный детектор честно считает это за “агент работает” и моргает на каждую нажатую клавишу. Поэтому короткие порции вывода (до 8 байт) сразу после нажатия (в пределах 250 мс) я за активность не считаю. Плюс счётчик activitySeq: если пока мы ждали ответа от /proc, прилетел новый вывод - старое решение выкидываем.
Мелочи, да. Но без них “умный индикатор” превращается в нервный тик.
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).
Отдельный поклон 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 берутся из той же палитры.
Классика, на которой спотыкается половина 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. И мне правда интересны ваши грабли в похожих задачах - особенно у кого как сделан кросс-платформенный детект состояния процессов. Делитесь, забирайте идеи.