Редактор, в котором главный — терминал: как я делал лёгкую IDE под эпоху ИИ-агентов

от автора

Последний год я почти перестал печатать код руками. Чаще просто диктую задачу агенту в терминале — 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/