TUI client for eva kernel
  • Python 96.9%
  • Nix 3.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Aleksandr 10679f876d Деньги — валютой ядра, а не вшитым долларом
Ядро научилось считать не только в долларах (`llm.currency`), и клиент
теперь подписывает каждое число тем, что ядро назвало: расход чата, окна
статистики, команду подагентов, счётчик идущего тура и шапки денежных
колонок.

USD остаётся привычным префиксом (`$0.031`), прочие валюты идут знаком
после числа (`0.031 ₽`). Символ клиент знает у USD, RUB и EUR, а
незнакомый ISO-код печатает как есть — `120.00 KZT`: угаданный символ
соврал бы, а код читается.

Валюту называют сами ответы про расход — какое число, такая и подпись.
Счётчику тура она не доезжает вовсе (деньги там копятся из событий
`usage`), и ради него клиент раз на ядро спрашивает `GET /v1/config`:
смена валюты значит перезапуск ядра, чаще незачем. Старое ядро поля не
отдаёт — тогда доллар, и это его прежнее поведение, а не догадка.

Ключ `tui.spend.col.reasoning_cost` принял `{currency}`; оверлей,
написанный до этого, работает как написан.

Co-Authored-By: Eva
2026-09-04 17:57:39 +03:00
docs Строки уезжают в свой конфиг, ядро отдаёт лишь имя 2026-08-23 18:25:17 +03:00
nix Голос из коробки: home-manager-модуль со слоями персоны 2026-08-23 18:56:37 +03:00
src/eva_tui Деньги — валютой ядра, а не вшитым долларом 2026-09-04 17:57:39 +03:00
tests Деньги — валютой ядра, а не вшитым долларом 2026-09-04 17:57:39 +03:00
.gitignore Терминальный клиент: Textual поверх API ядра 2026-07-02 16:30:10 +03:00
AGENTS.md Записка догоняет стрим и запоминает правило про ссылки 2026-08-08 14:03:35 +03:00
CHANGELOG.md Деньги — валютой ядра, а не вшитым долларом 2026-09-04 17:57:39 +03:00
FEATURES.md Деньги — валютой ядра, а не вшитым долларом 2026-09-04 17:57:39 +03:00
flake.lock Терминальный клиент: Textual поверх API ядра 2026-07-02 16:30:10 +03:00
flake.nix Формулы в терминале: юникод вместо вёрстки 2026-08-25 16:26:28 +03:00
pyproject.toml Формулы в терминале: юникод вместо вёрстки 2026-08-25 16:26:28 +03:00
README.md Деньги — валютой ядра, а не вшитым долларом 2026-09-04 17:57:39 +03:00

eva-tui 💻

Я в твоём терминале, Господин. Textual + httpx, тонкий клиент к eva-kernel — весь ум в ядре, здесь только лента и клавиши.

Что умеет

  • Чаты — все твои разговоры, откуда бы ты их ни завёл: терминал, телефон, браузер — окна в одно и то же. Создать ctrl+n (Enter — обычный, t — временный; название чату придумает модель по первому сообщению), удалить ctrl+x (с подтверждением), обновить ctrl+r. Полка (folder) — про что разговор, а не кто его завёл: ctrl+f выбирает, что показывать — все мои чаты, одну полку или поверхность (телеграмная личка, мастерская переводов — их чаты заводит не Господин и их десятки, поэтому в общем списке их нет, пока не спросишь). Кодовые сессии всех проектов лежат на полке code, и различает их поле project: пункт «Код» открывает их одним списком, а проект подписан в строке чата. Новый чат ложится на ту полку, на которую сейчас смотрит список; смотришь на всё или на «Код» — в «Общее» (так зовётся полка по умолчанию): кодовую сессию заводит клиент в каталоге проекта, а заведённой из списка неоткуда взять проект. Свои проблемные чаты Ева складывает на полку problems — она там же в списке. Подпись над списком говорит, что сейчас показано (полка, «мои чаты», поверхность или «🗑 корзина»), в строке чата серым стоит время последней активности, а когда список смешивает полки — полка чата или проект кодовой сессии.
  • Переложить чатctrl+o, затем f: полка из списка или новая по имени (её достаточно вписать). Разговор сменил тему — сменил и полку.
  • Внимание — чаты, куда Ева зовёт Господина, помечены жёлтым и идут первыми в списке; f2 снимает пометку с выделенного (и вешает обратно, если чат хочется отложить).
  • Корзина — удаление мягкое, история цела: f3 показывает только удалённые чаты (в обычном списке их нет), f4 возвращает выделенный.
  • Сессии проекта/sessions в кодовом режиме: прежние разговоры этого проекта — название, когда последний раз говорили, во что обошлись; Enter открывает и перепривязывает .eva/session, d удаляет.
  • Сессия привязана к каталогу.eva/session помнит чат этого рабочего каталога. В .gitignore: в клоне на другой машине сессия начнётся с чистого листа, а не подхватит переписку про чужие файлы. ctrl+n начинает новую и перепривязывает; прежние видны из обычного режима.
  • Правила и память проектаAGENTS.md и .eva/MEMORY.md собирает само ядро: в отправке едет project_docs, и оно раз на тур забирает ВСЕ такие файлы от корня проекта вниз до рабочего каталога, ближний — последним. Клиент тут только руки: служебная операция list_up идёт по дереву вверх и говорит, какие из спрошенных имён где есть. Индекс MEMORY.md едет Еве в промпт, тело факта она читает сама, когда крючок совпал, и пополняет память по ходу дела. Коммитится с проектом, так что переезжает вместе с ним.
  • Подагентыf7 (или /agents): кого Ева поставила на работу в этом чате — имя, статус, модель, набор инструментов и во что обошёлся (4500 ток $0.0310 думы 1200 · $0.0024; цены выходного токена у модели нет — вместо суммы прочерк). В шапке — итог по команде: подагенты: 3 · 12.3k ток · $0.031 · думы 2.0k · ≥ $0.002, где означает, что часть подагентов думала моделями без цены и итог занижен, а «цена неизвестна» — что цены нет ни у одной. enter открывает чат подагента: он и есть чат, так что видно всю его работу целиком, живую в том числе. d останавливает. Ядро без /v1/agents — панель просто пуста.
  • Очередь на одобрениеf5: Ева спрашивает разрешения асинхронно, вне тура; вопрос, детали и время — списком, a одобряет, r отклоняет, решённое сразу уходит. Пока очередь не пуста, счётчик висит в шапке (в код-режиме — в строке режима снизу).
  • Стриминг — речь Евы делится надвое: то, что уже не изменится, уходит в ленту отдельными виджетами и больше не трогается, а перерисовывается один подвижный хвост. Фиксируется только целый блок markdown и только когда виден следующий: недописанная строка оставила бы **bo открытым жирным, а разорванный пополам ```-блок отрендерился бы мусором. Начатая таблица живёт в хвосте до конца тура — новая строка меняет ширины всех колонок и переливает предыдущие. Вызовы инструментов показываются пометками с иконкой и краткой сводкой. При старте клиент забирает GET /v1/tools и строит карту name → {icon, brief}: иконка перекрывает встроенную эвристику категорий, brief-шаблон ({from} → {to}, {opts.date} и т.п.) подставляется из аргументов вызова; без каталога или шаблона — прежнее поведение («🌐 Поиск: „…"», «💻 Команда: …»; MCP без каталога — сырое имя). Долгие тулы обновляют пометку живым прогрессом. Пока тур жив, над полем ввода крутится статус со спиннером, сменными глаголами, секундомером и счётчиком тура — какой идёт шаг, сколько токенов ушло вверх/вниз, сколько выхода забрали мысли и во сколько это уже обошлось (✳ копаюсь… (47s · шаг 6 · ↑12.4k ↓1.8k (думы 900) · $0.031 · esc — прервать); думы — часть выхода, а не добавка к нему). Секундомер стоит, пока тур упёрся в решение Господина — диалог разрешения записи или команды, кнопочный вопрос: «работала пять минут», из которых четыре Господин смотрел на диалог, — неправда, и статус в это время так и говорит: жду вашего слова… (47s · секундомер стоит). Esc останавливает генерацию (частичный ответ сохраняется в ядре) и заодно убивает команду, которую клиент выполняет прямо сейчас — «стоп» останавливает то, что видно на экране. Без идущего тура Esc стирает набранное. Тур длиннее полминуты звонит в терминал по завершении. Туры идут доверенными и без потолка токенов (trusted + uncapped).
  • План работы — взявшись за большое дело, Ева ведёт себе список задач; клиент показывает его строкой над вводом, рядом со статусом: ▸ провод: строка плана над вводом · сделано 1 из 3 — активная задача (с фазой, если она есть) и сколько уже позади. Событие plan присылает состояние целиком, а не правку; пустой список — работа кончилась, и строка уходит. При открытии чата и по концу тура план перечитывается из GET /v1/chats/{id}/plan: событий могло не быть вовсе — подхватили чужой тур или план закрылся молча. Брошенные задачи считаются отдельно — брошенное не сделанное.
  • Выхлоп инструмента — если из результата видна только часть, под ним встаёт «⎿ выхлоп целиком (N строк)»: Enter разворачивает его прямо в ленте (до 200 строк), не открывая сводку. Из истории выхлоп поднимается тем же блоком.
  • Картинки — Ева умеет рисовать, но терминал не галерея: нарисованное встаёт в ленту пометкой 🖼 картинка (image/png) · https://…/images/…. Ссылка открывается руками — по ней ядро отдаёт байты. Картинка приходит посреди речи, поэтому пометка встаёт между текстовыми пузырями, ровно там, где Ева её нарисовала; из истории она поднимается тем же видом шага.
  • Размышления — пока Ева думает, в хвосте ленты курсивом идут три последние строки её мыслей; законченный кусок схлопывается в «💭 размышления (N строк)» и разворачивается по месту (Enter на нём). Из истории они поднимаются в ленту вместе с остальным.
  • Сводкаctrl+y: таймлайн шагов чата, как в android-клиенте — размышления и вызовы инструментов по порядку; Enter раскрывает шаг до полных аргументов и выхлопа.
  • Блокнот чатаf10 (или /notes): заметки, которые Ева решила не потерять — решение и его причина, инвариант проекта, добытый раскопками путь, тупик, куда уже ходила. Список имя — описание (закреплённые наверху с 📌), тело выбранной — под ним; d забывает заметку, p вешает и снимает булавку, c пересобирает блокнот (отчёт ядра — в уведомлении), r возвращает версию, отложенную перед последней пересборкой. В подписи — режим и размер (inline · 1234 симв.): в inline блокнот едет модели телами дословно, в index — одними именами с описаниями, так что видно, почему она перестала видеть тела. Изменившееся с прошлого захода помечено «новое»; отметка просмотра серверная, поэтому web, TUI и android считают новым одно и то же. Правка тела заметки живёт в веб-клиенте.
  • Модельctrl+p: список из /v1/models; Enter — модель текущему чату (или вернуть на дефолт ядра), d — дефолт для новых чатов этого ядра (клиентская настройка, переживает перезапуск). Выбранная модель сразу видна в подзаголовке шапки (в код-режиме — в рамке сессии); чат без своей модели показывает модель ядра с пометкой «дефолт».
  • Уровень рассужденияu на экране модели (или /effort): насколько сильно Ева думает перед ответом в этом чате. Ступени берутся у ядра (effort_levels); выше ступень — дольше и дороже, зато решает больше. «Как решит ядро» снимает выбор чата, оставляя ступень записи модели, а нет и её — глобальную из конфига; там же сказано, что именно достанется взамен. Действующая ступень стоит рядом с моделью — думы high, а скобки («модель», «дефолт») говорят, что выбирал её не чат.
  • Набор инструментовf9 (или /toolset): наборы из /v1/toolsets, Enter применяет выбранный к открытому чату, «весь реестр ядра» снимает набор. Кодовой сессии осмысленно жить на узком наборе, а болталке — на широком.
  • Кнопочные вопросы — когда Ева спрашивает через ask_questions, в ленте вырастает список вариантов с указателем : стрелки двигают, Enter отвечает, цифра отвечает сразу; «💬 Дообсудить» — последний пункт за чертой. Пачка из нескольких вопросов листается плашками сверху (/), ответ сам открывает следующий неотвеченный. Tab в выборе не участвует и по кнопкам интерфейса не скачет. После ответа список сменяется «/»-записью тула. Если вопроса ядро уже не ждёт (истёк, отвечен с другого клиента), пункты снова оживают, а внизу всплывает «вопрос уже неактуален».
  • Markdown — ответы Евы рендерятся markdown'ом (код, списки, таблицы); реплики пользователя показываются как есть.
  • Формулы$…$ и $$…$$ из ответов Евы приближаются юникодом: $\mathrm{H_2SO_4}$ → «H₂SO₄», $\int_0^1$ → «∫₀¹». Настоящей вёрстки в терминале не будет — это приближение, а не KaTeX из веба. Границы формулы берутся как в VS Code: «$» открывает её, только если перед ним не буква и не цифра, и закрывает, только если после него не буква и не цифра, — так «$5 и $10» остаются деньгами. Содержимое ```-блоков и кода не трогается, выключенная формула встаёт отдельным блоком, а не влипает в абзац. Перевести не вышло — на экран идёт исходный TeX.
  • Лента элементов — и история (GET /v1/chats/{id}/items), и живой тур (item_starteditem_deltaitem_completed) говорят одним видом шага: реплика, размышление, вызов с вложенным результатом, картинка, служебная вставка ядра, граница компакции. Клиент не сшивает /messages с событиями в свою третью модель, а легаси-набор тех же событий глушит у ядра полем mute. Нарисованное по item_started правится по item_completed — он авторитетен. Чем кончился вызов, говорит его статус: failed — красная пометка с началом ошибки, aborted — «не выполнен: тур оборван».
  • История страницами — открывается хвост из 50 сообщений, PageUp у верхнего края догружает старше; пока сверху что-то осталось, над лентой висит строчка «⋯ PageUp — старше». Вся история не тянется никогда.
  • Правка прошлогоf8 (или /rewind): список своих реплик. Enter заводит ВЕТКУ перед выбранной — новый чат с копией истории до неё, а её текст ложится в поле ввода: исправленный ход уедет в ветку, прежний разговор останется целым (в /sessions ветки помечены «⑂»). d стирает реплику и всё после — невозвратно, и потому спрашивает.
  • Многострочный вводenter отправляет, alt+enter (или shift+enter, или ctrl+j) переносит строку; поле растёт по содержимому до восьми строк, вставка ложится абзацем как есть.
  • Очередь во время тура — поле живо, пока Ева работает: отправленное ждёт своей очереди («⏎ в очереди: N» под полем) и уходит по одной реплике следующими турами. Увидел, что Ева пошла не туда, — дописал сразу, не дожидаясь конца. Esc снимает слои по очереди: сперва очередь, потом тур, потом набранное. Смена чата очередь сбрасывает.
  • История ввода/ листают последние 50 отправок (в код-режиме — когда меню «/» закрыто; внутри абзаца стрелки двигают курсор, история листается с краёв текста), ниже самой свежей возвращается недописанная строка. Сорвавшийся тур кладёт текст обратно в поле, чтобы не набирать его заново.
  • Спикеры — чужие реплики в общих чатах подписаны именем личности из базы ядра (жёлтым); свои — «Господин».
  • О чатеctrl+o: метаданные (полка и поверхность, модель, набор чата, сколько инструментов у него в контексте и когда ему спать, даты), саммари компакции, компакция по кнопке (c), перекладывание на другую полку (f) и набор по умолчанию (t). Спросить Еву о саммари можно прямо в чате — тур его видит.
  • Кронctrl+t: таймеры с удалением; r — журнал исполнений (чем кончилось и что вышло, запись раскрывается).
  • Сон/sleep в кодовом режиме: время от времени Ева спит, разбирая долговременную память, и пока спит — не отвечает, туры ждут пробуждения. Без хвоста команда показывает состояние (спит — разбираю кластеры 2/7, уже 5m · прошлый сон 1h назад), now укладывает спать сейчас (now dry — вхолостую: план считается и ложится в журнал, но не применяется), wake будит (недоделанная фаза не применяется, применённое остаётся), log открывает журнал ночей: чем кончилась, сколько действий легло в память, каков был корпус, пометки «вхолостую» и «разбудили», Enter раскрывает отчёт ядра словами. Отказ ядра («уже сплю», «не сплю», «сон выключен») приходит уведомлением, а не ошибкой. Тур, застигший сон, показывает «сплю (фаза)» вместо обычного «думаю» и подсказку /sleep wake; пока Ева спит, в строке режима (в обычном режиме — в шапке) висит 💤 спит. Состояние клиент узнаёт из события тура и своих же команд — ради отметки ядро он не опрашивает. chats открывает журнал снов чатов — у каждого чата он свой (см. ниже).
  • Сон чата и набор по умолчанию/tools (или t в «о чате», s в наборах инструментов). У каждого чата свой ленивый сон, и он курирует то, что чат видит в промпте по умолчанию: болталка про аниме перестаёт таскать схемы mcp_forgejo, кодовая сессия — maps_search. Это слой внимания, а не прав: скрытое не отобрано, оно осталось в реестре скрытого именем и назначением и возвращается одним tool_load, — поэтому панель называет две разные величины: «в контексте сейчас: 15 из 77 досягаемых» и набор чата (toolset), который и есть права. Считает тулы ядро — у него гейты чата и его доступ; клиент не считает ничего сам, потому что записи набора это имена тулов и целых семейств вперемешку: те же 15 тулов приезжают шестью записями. Чату, не ходившему после апгрейда, ядро цифр не даёт, и панель говорит это словами, а не нулём. Курирование приходит от тесноты, а не от молчания: влезает набор в бюджет — ночь не трогает ничего, и молчащий тул этим не наказан. В панели видно причину (why), хранимое решение (имена тулов, семейств, set:<набор>) и раскрытый набор списком: закреплённое помечено 📌, обязательное ядро подписано «ядро». Чат ещё не курирован — списком идёт всё досягаемое ему: набора нет, а булавку ставить уже есть на что. p закрепляет инструмент или снимает булавку — закреплённое ни одна ночь не спрячет, и ждать первой ночи не надо: булавка сама курирования не заводит (набор остаётся пустым, чат по-прежнему видит всё), она свяжет руки первому же сну. x снимает курирование целиком, s усыпляет чат сейчас (d — вхолостую: план ляжет в журнал, набор не тронется), l открывает журнал его снов (+2/7, модель, отчёт и план словами), t — таблицу «чем пользуется чат» за неделю/месяц/квартал. В таблице главное — покрытие: в скольких турах из скольких тул звался хоть раз («раз в месяц и всегда» — это 1/1, а не «1 вызов за месяц»); а «вернули» считает, сколько раз тул доставали обратно рукой — столько раз курирование спрятало не то. Пока у чата стоит набор инструментов, курирование поверх него не применяется вовсе, и шапка об этом говорит.
  • Несколько ядерctrl+b: переключение между серверами из [[servers]] конфига; выбор переживает перезапуск.
  • Расходctrl+s: окна час/сегодня/неделя/месяц (14), итог, триаж, разбивка по моделям, источникам туров и чатам. Думы — своей строкой (думы: 12.3k из 40.1k выхода (31%) · $0.0123): это доля выхода, а не добавка сверх него, и колонки думы и цены дум в разбивках показывают ту же ось по моделям и источникам. Деньги за думы считаются только по моделям с price_out в конфиге ядра: у модели без цены вместо суммы прочерк, а итог при смеси идёт под с объёмом дум, оставшихся без цены. Дум в окне не было — ни строки, ни колонок. Кэш промпта — тоже своей строкой (кэш: 120.4k из 138.2k промпта (87%) · запись 30.0k) и колонками кэш/запись в тех же разбивках: видно не только «кэш работает», но и кто именно ходит мимо него. Запись стоит дороже обычного промпта, поэтому окно, где записано много, а прочитано ноль, названо прямо — 12.0k записано, ни одного чтения: это инвалидатор, что-то в начале промпта меняется от тура к туру. Кэша в окне не было — ни строки, ни колонок.
  • Расход чатаf6: сколько стоил открытый чат, сколько весит его контекст сейчас, какая доля промпта пришла из кэша (и сколько в кэш записано) и какая доля выхода ушла в думы ($0.031 · ctx 34% (68.2k) · кэш 87% (+30.0k) · думы 30% ($0.004); цены выходного токена у модели нет — скобок нет). Процент у ctx — доля окна модели, занятая контекстом: считается от её потолка за вычетом стабильного префикса тура (персона, схемы инструментов, правила), иначе пустой чат начинался бы не с нуля. Потолка модель не объявила — остаются одни токены. Читать из кэша нечего, а запись есть — так и сказано: запись в кэш 30.0k впустую. Живёт правым краем строки режима (в код-режиме) или в подзаголовке шапки — ленту не закрывает; на узком терминале ужимается до денег. Обновляется при открытии чата и после каждого тура; тура ещё не было — вместо веса прочерк. В код-режиме показан сразу, в обычном включается тем же f6; выбор помнится. Ядро без /v1/chats/{id}/spend — строки просто нет.
  • Валюта денег — та, в которой считает ядро (llm.currency); клиент ничего не конвертирует, а только подписывает. Доллар остаётся привычным префиксом ($0.031), прочие валюты идут знаком после числа (0.031 ₽), а незнакомый ISO-код печатается как есть — 120.00 KZT. Валюту называют сами ответы про расход; счётчику идущего тура, где её нет, клиент спрашивает GET /v1/config — раз на ядро. Старое ядро валюту не называет, и тогда это доллар, как оно и считало.
  • Телеграм-настройкиctrl+d: дебаунс триажа, потолок ответа и краткость по чатам и пользователям; Enter — правка, пустое поле — глобальная логика ядра.
  • Клиентские операции — Ева читает/правит файлы (read_file, write_file, write_diff), смотрит и патчит двоичные (read_hex, write_hex, find_hex) и гоняет local_shell прямо на этой машине: клиент исполняет client_op из стрима и постит результат обратно. Относительные пути и каталог команды — от рабочего каталога чата (его ставит workdir), а без него — от каталога запуска. Долгая команда показывает, чем занята: клиент читает вывод потоком и шлёт ядру снимки хвоста, а оно превращает их в живой прогресс того самого вызова — в код-режиме он виден строками «⎿ …» под ним. Двоичные операции возят байты в base64: читается ровно затребованное окно, запись ложится по смещению поверх существующего, поиск течёт файлом окном по мегабайту — архив на гигабайты в память не ложится. Разрешения: по умолчанию ask-режим — запись и shell подтверждаются (enter или y — да, esc или n — нет; чтение, поиск и служебный подъём свободны); --yolo стартует с auto-режимом (разрешено всё), shift+tab переключает режим на лету. Набор ответов задаёт ядро, а не клиент: он рисует те, что умеет, и молча пропускает незнакомые — новое решение вводится правкой ядра. Молчаливого закрытия у диалога нет: esc — это отказ, и он уезжает ядру решением. Сам ядро диалог тоже снимает — когда операция истекла или тур кончился; тогда решения Господин не принимал, и от его имени клиент ничего не отвечает. Запрос на запись показывает дифф — что лежит на диске против того, что Ева собирается записать, — чтобы решать не по одному имени файла; у двоичной правки в запросе смещение (десятичное и шестнадцатеричное), объём и начало байтов в hex. Послабления: a (или ф) — «до конца сессии», ! — «и дальше» (переживает перезапуск, привязано к рабочему корню). Команда запоминается по первому слову (cargo, git), запись — по каталогу внутри рабочего корня. Что уже разрешено, покажет /mode; /new сбрасывает сессионные. Операция из реплея /live рисуется, но не исполняется: после переподключения второй записи в файл не будет.
  • Свои тулы клиента — тем, до чего ядру не дотянуться, терминал распоряжается сам: он объявляет модели свои инструменты на каждый тур (поле extra_tools в send_message), она зовёт их как обычные, а исполняет их клиент. Сейчас это copy_to_clipboard — положить текст в буфер обмена Господина, чтобы он вставил его куда нужно. Ответ уезжает тем же проводом, что и клиентские операции, и по тем же правилам: реплей рисуется, но не исполняется, а упавший обработчик отвечает модели текстом ошибки — молчание стоило бы ядру полного таймаута. Объявления лежат в extra_tools.py; имя, тенящее тул реестра, ядро отвергает вместе с туром.
  • Код-режим--code [DIR]: сессия проекта в интерфейсе Claude Code, корень операций DIR (по умолчанию cwd), сессии на полке code с именем каталога в project и автопродолжением последней (/new — новая). Экраны открываются «/»-меню, а не ctrl-клавишами: наборы хоткеев режимов не пересекаются. Каждый тур уезжает с полем context (фрейминг кодовой сессии — правила проекта собирает ядро само), с project_docs и с uncapped_iterations: потолок итераций снят, длинная правка не обрывается на середине. Ядро без поддержки полей молча их игнорирует.
  • Голос — всё, что видно на экране, лежит ключом в каталоге сообщений (messages.py + locale/builtin.json), и встроенный каталог нейтрален: из коробки клиент не называет ни имени, ни обращения и не говорит о себе в женском роде. Складывается голос из двух независимых источников. Строки — из своего конфига (overlay, путь к плоскому файлу «ключ → строка либо список строк»): о клиентских интерфейсах ядро не знает ничего, и через него они не ездят. Имя и обращение — от активного ядра, GET /v1/persona отдаёт {name, honorific}; они подставляются в любую строку как {name} и {honorific}, объявлять их не надо, а сам файл переживает их смену. Оверлей переписывает любой ключ, чего в нём нет — берётся из встроенного: неполный оверлей норма, а не поломка; счётное держит формы суффиксами .one/.few/.many, список вариантов — массивом. Испорченная подстановка молча уступает встроенной строке, а ключ, которого нет нигде, возвращается собой — пропажа видна на месте, а не пустотой. Тем же путём уступает и строка, которой нужно {honorific}, пока обращения нет: источники независимы, файл читается всегда, а имя ждёт ядра — и «С возвращением, !» на мёртвом ядре хуже нейтрального приветствия. Файла нет или он битый — предупреждение и нейтральные строки: это клиент, а не ядро, и падать из-за опечатки в конфиге ему не за что. Имя от ядра кладётся в state-файл по имени ядра, так что недоступное ядро отвечает из памяти прошлого раза, а 404 у старого ядра читается как «имени нет». Прежний голос Евы собран целиком в docs/persona-overlay.example.yaml — готовый файл для overlay. Одно исключение: подписи клавиш Textual читает на определении класса, раньше всего остального, поэтому их переписывает только встроенный каталог.

Хоткеи

Наборы режимов разведены: клавиша обычного режима в кодовой сессии не срабатывает и не светится в подсказках, всё нужное там делает «/»-меню. Общие на оба режима — только идиомы самой ленты: escape (прервать тур), shift+tab (разрешения auto/ask), PageUp (страница истории старше), f6 (расход), f7 (подагенты), f8 (правка прошлого), f9 (набор инструментов), f10 (блокнот чата) и f1 (что я умею).

f1 (или /help) показывает всё это прямо в клиенте: клавиши по группам, «/»-команды и три конвенции. Список собирается из привязок и команд самого клиента, поэтому устареть не может, и показывает только те клавиши, что живы в текущем режиме.

Буквенные клавиши экранов понимают и русскую раскладку: d/в, a/ф, r/к, c/с, t/е, f/а — терминал отдаёт символ, а не физическую клавишу, и без двойника с ЙЦУКЕН они молчали.

Обычный режим

Клавиша Что делает
ctrl+n новый чат (Enter — обычный, t — временный)
ctrl+x удалить чат (мягко, с подтверждением)
ctrl+r обновить список
ctrl+l фокус: список чатов ↔ поле ввода
ctrl+o о чате (f внутри — переложить на другую полку)
ctrl+y сводка шагов
ctrl+p модель (u внутри — уровень рассуждения)
ctrl+e память
ctrl+t крон
ctrl+g идущие туры
ctrl+s расход
ctrl+d телеграм-настройки
ctrl+b ядра
ctrl+f что показывать: мои чаты, полка или поверхность
f2 снять/вернуть пометку внимания
f3 удалённые чаты (корзина)
f4 вернуть удалённый чат
f5 очередь на одобрение
f6 расход
f7 подагенты открытого чата
f8 правка прошлого: ветка или стирание
f9 набор инструментов чата
f10 блокнот чата
f1 что я умею
ctrl+q выход

Код-режим

Клавиша Что делает
/ меню команд: стрелки — выбор, tab — дополнить, enter — выполнить
escape прервать тур
shift+tab разрешения auto ↔ ask
PageUp страница истории старше
f6 расход
f7 подагенты открытого чата
f8 правка прошлого: ветка или стирание
f9 набор инструментов чата
f10 блокнот чата
f1 что я умею

Команды меню: /help, /new, /sessions, /model, /effort, /toolset, /mode, /compact, /clear, /rewind, /summary, /notes, /info, /agents, /approvals, /stats, /memory, /sleep, /cron, /gens, /servers, /telegram, /quit. Хвост после имени достаётся команде аргументами: /sleep now dry, /sleep wake, /sleep log.

Запуск

nix develop -c python -m eva_tui.app --url http://127.0.0.1:8090
# или: nix run . -- --url ...

Адрес ядра ищется в таком порядке: --urlEVA_TUI_URL~/.config/eva-tui/config.toml ([kernel] url = "...") → localhost:8090. Токен (если ядро за прокси с проверкой X-Eva-Token) — так же, причём на каждом слое можно дать значение или файл с ним: --token/--token-fileEVA_TUI_TOKEN/EVA_TUI_TOKEN_FILE[kernel] token/token_file.

Дополнительные ядра — секциями [[servers]] в том же конфиге (первым в списке всегда идёт «default» из слоёв выше):

[[servers]]
name = "home"
url = "https://eva.desu.church"
token_file = "/run/secrets/eva-tui-token"  # или token = "..."

Оверлей строк — тоже конфигом, ключом overlay: путь к плоскому файлу «ключ → строка либо список строк» (образец — docs/persona-overlay.example.yaml). Общий лежит в [kernel], а у ядра может быть свой — он перекрывает общий:

[kernel]
overlay = "~/.config/eva-tui/voice.yaml"

[[servers]]
name = "work"
url = "https://eva.example.com"
overlay = "~/.config/eva-tui/work-voice.yaml"  # своё ядро — свой голос

Клиентские настройки (активное ядро, дефолтные модели новых чатов, запомненное имя каждого ядра) живут в ~/.local/state/eva-tui/state.json — правятся клавишами, не руками.

NixOS-модуль

Флейк отдаёт nixosModules.default — клиент ставится системно, адрес ядра живёт прямо в конфиге системы (обёртка задаёт EVA_TUI_URL, --url из рук всё ещё сильнее):

{
  inputs.eva-tui.url = "git+ssh://forgejo@git.viende.su:61488/eva/tui.git";

  # в конфигурации:
  imports = [ inputs.eva-tui.nixosModules.default ];
  programs.eva-tui = {
    enable = true;
    url = "https://eva.desu.church";
    tokenFile = "/run/secrets/eva-tui-token";  # например, из sops-nix
  };
}

home-manager-модуль

homeManagerModules.default ставит клиент пользователю, пишет ~/.config/eva-tui/config.toml и — главное — приносит голос из коробки. Персонный оверлей, сгенерированный из документа персоны, лежит в репе (nix/persona-overlay.default.json, 76 ключей) и включён по умолчанию:

imports = [ inputs.eva-tui.homeManagerModules.default ];
programs.eva-tui = {
  enable = true;
  settings.kernel.url = "https://eva.desu.church";

  # поправить одну строку, не трогая остальные 75
  persona.overlay."tui.banner.greeting" = "Доброй ночи!";
};

Слои голоса — от дешёвого к решительному:

опция что делает
persona.useDefaults сгенерированный оверлей целиком; false — начать с пустого
persona.overlay поправки по ключам поверх дефолтов; ключа нет — остаётся дефолтный, нет и там — встроенная нейтральная строка
persona.overlayFile готовый файл оператора целиком, поверх всего: в чужой файл сливать нечего
settings.kernel.overlay сырой аварийный выход, сильнее всего перечисленного

Имя и обращение сюда не входят — они приезжают от ядра (GET /v1/persona) и подставляются в эти строки как {name} и {honorific}. Дефолт лежит JSON'ом, а не YAML, потому что nix читает JSON сам: fromJSON (readFile …) обходится без IFD. Что модуль насчитал по слоям, проверяется на eval — nix flake check, девять сценариев без сборки.

Карта

nix/            # persona-overlay.default.json — голос по умолчанию для модуля
src/eva_tui/
├── messages.py # каталог сообщений: t/tn/tl, чтение оверлея из конфига
├── locale/     # builtin.json — нейтральный каталог, полный и встроенный
├── kernel.py   # REST + разбор SSE руками, полки и поверхности, PAGE_SIZE
├── tools.py    # лицо инструментов: иконки, brief-шаблоны, каталог /v1/tools
├── stream.py   # стрим надвое: граница фиксации, придержание таблиц
├── formulas.py # LaTeX в ответах Евы → юникод: индексы, символы, стрелки
├── feed.py     # лента элементов: курсор страницы, статусы, шаги сводки
├── project.py  # подъём по дереву для служебной операции list_up
├── extra_tools.py  # свои тулы клиента: объявления на тур и их лицо в ленте
├── token_usage.py  # доля занятого окна: usage против потолка минус префикс
└── app.py      # Textual: список чатов, лента, стрим, пагинация, экраны
tests/          # чистая логика без терминала и ядра

Тесты — стандартным unittest, зависимостей сверх клиента не надо:

nix develop -c python -m unittest discover -s tests