javascript

TUI-агент включает alt-буфер один раз за сессию. Через 256 КБ вывода это ломает терминал

  • среда, 2 сентября 2026 г. в 00:00:06
https://habr.com/ru/articles/1077252/

Короткая версия. Жалоба звучала как «скролл нестабильный»: колесо в панели с агентом то работает, то нет, текст съезжает, кадры дублируются. Виноват не скролл. Claude Code под ConPTY включает альтернативный буфер и отчёты мыши один раз при старте и больше их не переотправляет, а replay-буфер панели ограничен 256 КБ. За долгую сессию эти включения вымывает за границу буфера, и свежий xterm после реаттача остаётся в нормальном буфере, хотя агент давно в альтернативном. Ниже механика, фикс на 60 строк и ещё пять граблей того-же слоя, каждая из которых снаружи выглядит как «терминал сломан».

Симптом

Панель с агентом переживает реаттач чаще, чем кажется: перезагрузка окна, доставка хотфикса в renderer, смена воркспейса, вынос панели в отдельное окно. Каждый раз renderer подключается к живому pty заново и проигрывает хвост вывода.

После такого реаттача колесо мыши в панели начинает вести себя произвольно. Иногда прокручивает историю, иногда нет. Кадры интерфейса агента копятся дублями. Клики иногда не доходят.

Почему replay не спасает

Терминальное состояние живёт не в тексте, а в приватных DEC-режимах, которые приложение включает управляющими последовательностями. Альтернативный буфер это ESC[?1049h, отчёты мыши ESC[?1003h плюс кодировка ESC[?1006h.

Ключевое свойство TUI-приложения: оно шлёт эти включения один раз при старте. Дальше оно просто рисует. Через час работы включения лежат где-то в самом начале потока, а хвост, который проигрывается при реаттаче, их не содержит.

Дальше расходятся два состояния. Агент считает, что терминал в alt-буфере с включённой мышью. Свежий xterm считает, что он в нормальном буфере без мыши. Колесо уходит в scrollLines вместо проброса агенту, кадры агента ложатся в нормальный буфер поверх истории, клики бейлятся по проверке режима мыши.

Увеличить буфер это не лечит, а откладывает: 256 КБ становятся 2 МБ, и тот-же баг приходит через день, а не через час.

Фикс

Смотреть на поток вывода и помнить состояние. Отдельный трекер получает те-же чанки, что и renderer, вылавливает в них приватные режимы регуляркой \x1b\[\?([\d;]+)([hl]) и держит текущее состояние. При реаттаче он отдаёт минимальную последовательность, приводящую свежий xterm в это состояние, и она дописывается префиксом к replay-буферу.

Что нужно помнить:

Режимы

Что это

1049, 1047, 47

альтернативный буфер

9, 1000, 1002, 1003

уровень отчётов мыши

1006

SGR-кодировка координат мыши

2004

bracketed paste

1004

отчёты фокуса

1

DECCKM, стрелки шлют ESC O A вместо ESC [ A

Две вещи, без которых это не взлетит. Параметры в последовательности бывают склеены (?1000;1002;1003;1006h), разбирать надо каждый. И применение должно быть идемпотентным: если включения всё-же оказались в хвосте, повторная установка тех-же режимов ничего не должна портить.

Дешёвый гейт на входе экономит заметно: в подавляющем большинстве чанков приватных режимов нет вообще, поэтому сначала indexOf('\x1b[?'), и только потом регулярка.

Пять соседних граблей

Отчёт мыши считается вводом пользователя. У xterm CoreMouseService.triggerMouseEvent вызывает triggerDataEvent(report, true), а scrollOnUserInput на этом прыгает вниз. Claude Code включает режим 1003, то-есть отчёт на каждое движение курсора. Итог: прокрученную историю сбивало простым шевелением мыши над панелью. Лечится scrollOnUserInput: false и своим scrollToBottom() в onData, но только если данные не являются отчётом мыши.

В alt-буфере колесо молча теряется. xterm отдаёт колесо приложению только если viewport.getLinesScrolled(ev) !== 0, а в alt-буфере прокручивать нечего, и события исчезают. Полноэкранные панели просто не листаются. Решение: слать колесо самим - агенту с включённым mouse reporting как SGR или X10-событие мыши, кодировку ловить по ?1006h/l в потоке, а обычным less и man слать стрелками. Проверено живьём: claude на \x1b[<64;col;rowM отвечает перерисовкой.

Гейт по режиму мыши выключает фичу ровно там, где она нужна. Кликабельные пути и ссылки унаследовали проверку «в режиме мыши ничего не делаем». А режим мыши в панели с claude включён всегда, всю сессию. Снаружи это выглядит как «ничего не работает вообще». В исходниках xterm 5.5 линкифайер висит на _screenElement и про режим мыши не знает, отчёт мыши уходит в pty отдельным обработчиком, так что ссылка и TUI прекрасно сосуществуют. Гейт был не нужен. Правило, которое я из этого вынес: прежде чем гейтить что-либо по режиму мыши, спроси, не выключаешь ли ты фичу в единственном месте, ради которого её делали.

Пробел ходит только через keypress. У xterm в evaluateKeyboardEvent default-ветка требует keyCode >= 48, а у пробела 32. Из keydown xterm не отдаёт ничего и событие не отменяет, символ доезжает только keypress’ом. Отсюда фирменная сигнатура бага: буквы целы, пробелы исчезают. Значит кто-то выше по дереву (свой обработчик, IME, диктовка) вызвал preventDefault() на keydown, и keypress не родился. Лечится тем, что пробел без модификаторов отправляем сами.

Ctrl+C в codex не работает, и это не терминал. Отдельная история, которая стоила мне трёх стендов. Codex 0.147 не реагирует ни на легаси 0x03, ни на запись win32-input-mode с обёрткой VK_CONTROL, как её шлёт Windows Terminal. Ни в простое, ни во время работы. То-же с Ctrl+D, Ctrl+U, Ctrl+W. Прерывание там Esc, и он сам это подсказывает в интерфейсе. Контроль на том-же стенде: claude на 0x03 реагирует, то-есть ConPTY доставляет всё корректно. Ещё деталь: codex держит только ?1004h и ?2004h, mouseTrackingMode у него none и буфер нормальный - в отличие от claude, который сидит в alt-screen с мышью. Два популярных агента ведут себя противоположно, и любое правило вида «агенты делают так» ломается на втором из них.

Чем это диагностировать

Три инструмента, которые сэкономили мне больше всего времени.

Снять сырой буфер панели и прогнать его через @xterm/headless, подобрав ширину по минимуму «мохнатых» строк. Дальше смотреть buffer.active.type и term.modes - они отвечают на вопрос «в каком состоянии терминал думает, что он находится» без всякой мистики.

Телеметрия по одной клавише. Для пробела это оказалось решающим: kd (keydown), kp (keypress), tx (ушло в pty). Диагноз выглядит как kd>0, kp=0, tx=0 и читается за секунду.

Отдельный стенд на настоящих нажатиях, отделяющий свою сторону от CLI. Он за две минуты отвечает на вопрос «мы не доставили или он не принял», и в случае с Ctrl+C ответ оказался «он не принял».

Что я про это думаю

Ощущение от такой отладки специфическое: почти каждый баг выглядит как «сломался скролл» или «сломалась клавиатура», а лежит на два слоя ниже - в контракте терминала, про который никто не думает, пока он работает. Ни одна из пяти граблей выше не находится чтением своего кода. Все нашлись только на стенде с настоящим агентом внутри.

Всё это живёт в VibeeCode, моём окне для CLI-агентов: панель там настоящий терминал на ConPTY, а внутри запущен claude, codex или что вы поставите. Собственно вся возня выше и есть цена за то, чтобы два разных агента жили рядом и вели себя как в родном терминале: vibeecode.ru/go/habr-conpty