diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..643a197 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,272 @@ +# AGENTS.md — Глобальные правила для AI-агентов + +> Этот файл читают все агенты: Codex, Gemini, Grok, Claude Code, Factory Droid. +> Он описывает **единственный** разрешённый рабочий процесс. +> Нарушение любого пункта = задача не принята. + +--- + +## 0. Главное правило + +**Каждое изменение в коде начинается с GitHub Issue и заканчивается закрытием этого Issue.** + +Агент не пишет код "в воздух". Нет Issue — нет работы. + +--- + +## 1. Перед началом любой задачи + +### 1.1 Найти Issue + +``` +gh issue list --state open --label agent +``` + +Работать только с Issue у которых: +- label `agent` — готово к выполнению агентом +- label `blocked` отсутствует +- assignee пустой или назначен на тебя + +### 1.2 Назначить себя + +``` +gh issue edit --add-assignee "@me" +gh issue comment --body "🤖 Начинаю выполнение. Агент: " +``` + +### 1.3 Прочитать Issue полностью + +Issue содержит: +- **Что сделать** — конкретная задача +- **Критерии приёмки** — как проверить что готово +- **Что нельзя трогать** — файлы и модули вне задачи + +Если чего-то не хватает для выполнения — оставить комментарий с вопросом, +снять assignee, **не начинать код**. + +--- + +## 2. Рабочий процесс (обязательный порядок) + +``` +Issue открыт + ↓ +git checkout -b issue-- + ↓ +Реализация (только в рамках Issue) + ↓ +cargo test --locked # все тесты зелёные +cargo clippy --all-targets -- -D warnings # ноль предупреждений +cargo fmt --check # форматирование чистое + ↓ +git commit -m "fix/feat/docs(): <описание> (closes #)" + ↓ +git push origin issue-- + ↓ +gh pr create --title "..." --body "Closes #" --base main + ↓ +gh issue comment --body "✅ PR готов: " +``` + +### Формат ветки + +``` +issue-42-add-daily-limit +issue-57-fix-ci-linux-gtk +issue-103-theme-dark-light +``` + +### Формат коммита + +``` +feat(monitor): add daily limit display (closes #42) +fix(ci): install gtk deps for linux gui build (closes #57) +docs(readme): add demo screenshots (closes #103) +``` + +Типы: `feat` `fix` `docs` `refactor` `test` `ci` `chore` + +--- + +## 3. Ограничения при реализации + +### 3.1 Трогать только то что в Issue + +Если Issue про `src/notify.rs` — не рефакторить `src/monitor.rs` попутно. +Попутные улучшения = отдельный Issue. + +### 3.2 Запрещено без явного указания в Issue + +- Менять `Cargo.toml` (добавлять/удалять зависимости) +- Менять `ci.yml` или `release.yml` +- Переименовывать публичные функции и структуры +- Менять формат JSON-вывода (`--json`) +- Трогать `SUBMISSION.ru.md` +- Коммитить `.env` файлы с ключами + +### 3.3 API-ключи + +Никогда не логировать `VIBEMODE_API_KEY`, `NEUROGATE_API_KEY`. +Если ключ попал в вывод — задача не завершена, нужен отдельный Issue на исправление. + +--- + +## 4. Проверка перед PR + +Все три команды должны завершиться с кодом 0: + +```bash +cargo test --locked +cargo clippy --all-targets -- -D warnings +cargo fmt --check +``` + +Дополнительно для GUI-изменений: + +```bash +cargo build --features gui --locked +``` + +Если тесты красные — **не создавать PR**. Исправить и перепроверить. + +--- + +## 5. Оформление PR + +```markdown +## Что сделано +<одна-две строки что изменилось> + +## Как проверить +<конкретные команды для ручной проверки> + +## Closes +Closes # +``` + +Не писать: "я сделал X, Y, Z, W" — только то что в Issue. + +--- + +## 6. После merge + +```bash +gh issue close --comment "✅ Выполнено в PR #" +git checkout main +git pull origin main +git branch -d issue-- +``` + +--- + +## 7. Если что-то пошло не так + +### Тесты не проходят после реализации + +```bash +gh issue comment --body "⚠️ Тесты не проходят: <вывод ошибки>. Нужна помощь." +``` +Снять assignee. Не пушить сломанный код. + +### Issue оказался больше чем ожидалось + +```bash +gh issue comment --body "⚠️ Задача требует изменений в — это за рамками Issue. Создаю дочерний Issue." +gh issue create --title "..." --body "Дочерняя задача от #" --label agent +``` + +### Конфликт с main + +```bash +git fetch origin main +git rebase origin/main +# решить конфликты +git push --force-with-lease origin issue-- +``` + +--- + +## 8. Labels (система меток) + +| Label | Значение | +|-------|----------| +| `agent` | Готово к выполнению агентом | +| `blocked` | Заблокировано — не брать | +| `needs-review` | Ждёт ревью человека | +| `bug` | Баг | +| `feat` | Новая фича | +| `ci` | CI/CD | +| `docs` | Документация | +| `gui` | Изменения в Slint GUI | +| `tui` | Изменения в ratatui TUI | +| `priority:high` | Сделать в первую очередь | +| `priority:low` | Можно отложить | + +Агент берёт задачи в порядке: `priority:high` → без приоритета → `priority:low`. + +--- + +## 9. Что агент НЕ делает самостоятельно + +- Не создаёт Issue сам (только человек) +- Не закрывает чужие Issue +- Не делает force push в `main` +- Не удаляет файлы без явного указания в Issue +- Не меняет версию в `Cargo.toml` (это делает человек перед релизом) +- Не создаёт теги и релизы + +--- + +## 10. Контекст проекта + +**Репозиторий:** https://github.com/xodapi/vimit +**Язык:** Rust 2024 edition +**Тесты:** `cargo test --locked` (58 тестов) +**GUI:** Slint (`--features gui`) +**TUI:** ratatui +**API:** `GET /v1/me` → `VIBEMODE_API_KEY` +**Платформы:** Windows x86_64, Linux x86_64/aarch64, macOS aarch64 + +Структура `src/`: +``` +src/ + main.rs — точка входа, CLI парсинг + api.rs — запрос /v1/me, модели данных + monitor.rs — TUI ratatui монитор + notify.rs — desktop notifications + cli/ + update.rs — self-update логика + bin/ + vimit-gui.rs — Slint GUI +ui/ + app.slint — главный Slint файл +``` + +--- + +## 11. Быстрый старт для нового агента + +```bash +# 1. Посмотреть открытые задачи +gh issue list --state open --label agent --assignee "" + +# 2. Взять задачу +gh issue edit --add-assignee "@me" +gh issue comment --body "🤖 Начинаю" + +# 3. Создать ветку +git checkout -b issue-- + +# 4. Сделать работу, проверить +cargo test --locked && cargo clippy --all-targets -- -D warnings && cargo fmt --check + +# 5. Коммит и PR +git commit -m "feat(...): ... (closes #)" +git push origin issue-- +gh pr create --fill --base main +``` +gh issue create \ + --title "feat(gui): replace 24h card with weekly credit donut chart" \ + --label "agent,gui,feat" \ + --body "..." \ + --repo xodapi/vimit \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 576edf6..22a7b97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -476,6 +476,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -842,6 +851,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "convert_case" version = "0.10.0" @@ -939,6 +954,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1029,6 +1053,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctor" version = "0.10.1" @@ -1051,11 +1084,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", - "rustc_version", + "rustc_version 0.4.1", "subtle", "zeroize", ] @@ -1117,7 +1150,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "zeroize", ] @@ -1156,7 +1189,7 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "rustc_version", + "rustc_version 0.4.1", "syn 2.0.118", "unicode-xid", ] @@ -1178,8 +1211,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", ] [[package]] @@ -1316,7 +1360,7 @@ dependencies = [ "curve25519-dalek", "ed25519", "serde", - "sha2", + "sha2 0.10.9", "signature", "subtle", "zeroize", @@ -1482,7 +1526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ "memoffset", - "rustc_version", + "rustc_version 0.4.1", ] [[package]] @@ -1608,6 +1652,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb17cf6ed704f72485332f6ab65257460c4f9f3083934cf402bf9f5b3b600a90" +dependencies = [ + "libc", + "rustc_version 0.2.3", + "winapi", +] + [[package]] name = "futures" version = "0.3.32" @@ -2209,6 +2264,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.10.1" @@ -2893,7 +2957,7 @@ checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ "proc-macro2", "quote", - "rustc_version", + "rustc_version 0.4.1", "simd_cesu8", "syn 2.0.118", ] @@ -4780,13 +4844,22 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver 0.9.0", +] + [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver", + "semver 1.0.28", ] [[package]] @@ -4946,7 +5019,7 @@ dependencies = [ "regex", "reqwest", "self-replace", - "semver", + "semver 1.0.28", "serde_json", "tar", "tempfile", @@ -4955,12 +5028,27 @@ dependencies = [ "zipsign-api", ] +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + [[package]] name = "serde" version = "1.0.228" @@ -5052,8 +5140,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -5105,7 +5204,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -5121,7 +5220,7 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" dependencies = [ - "rustc_version", + "rustc_version 0.4.1", "simdutf8", ] @@ -6296,6 +6395,7 @@ version = "0.6.2" dependencies = [ "chrono", "crossterm", + "fs3", "insta", "rand", "ratatui", @@ -6304,6 +6404,7 @@ dependencies = [ "self_update", "serde", "serde_json", + "sha2 0.11.0", "slint", "slint-build", "toml 0.8.2", diff --git a/Cargo.toml b/Cargo.toml index befa954..00cc38d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,8 @@ tray-icon = { version = "0.24.0", optional = true } toml = "0.8" rand = "0.9" self_update = { version = "0.41", default-features = false, features = ["archive-tar", "archive-zip", "compression-flate2", "compression-zip-deflate", "rustls"] } +sha2 = "0.11.0" +fs3 = "0.5.0" [dev-dependencies] insta = "1.48" diff --git a/docs/AGENTS (1).md b/docs/AGENTS (1).md new file mode 100644 index 0000000..289221a --- /dev/null +++ b/docs/AGENTS (1).md @@ -0,0 +1,279 @@ +# AGENTS.md — Глобальные правила для AI-агентов + +> Этот файл читают все агенты: Codex, Gemini, Grok, Claude Code, Factory Droid. +> Он описывает **единственный** разрешённый рабочий процесс. +> Нарушение любого пункта = задача не принята. + +--- + +## 0. Главное правило + +**Каждое изменение в коде начинается с GitHub Issue и заканчивается закрытием этого Issue.** + +Агент не пишет код "в воздух". Нет Issue — нет работы. + +--- + +## 1. Перед началом любой задачи + +### 1.1 Найти Issue + +``` +gh issue list --state open --label agent +``` + +Работать только с Issue у которых: +- label `agent` — готово к выполнению агентом +- label `blocked` отсутствует +- assignee пустой или назначен на тебя + +### 1.2 Назначить себя + +``` +gh issue edit --add-assignee "@me" +gh issue comment --body "🤖 Начинаю выполнение. Агент: " +``` + +### 1.3 Прочитать Issue полностью + +Issue содержит: +- **Что сделать** — конкретная задача +- **Критерии приёмки** — как проверить что готово +- **Что нельзя трогать** — файлы и модули вне задачи + +Если чего-то не хватает для выполнения — оставить комментарий с вопросом, +снять assignee, **не начинать код**. + +--- + +## 2. Рабочий процесс (обязательный порядок) + +``` +Issue открыт + ↓ +git checkout -b issue-- + ↓ +Реализация (только в рамках Issue) + ↓ +cargo test --locked # все тесты зелёные +cargo clippy --all-targets -- -D warnings # ноль предупреждений +cargo fmt --check # форматирование чистое + ↓ +git commit -m "fix/feat/docs(): <описание> (closes #)" + ↓ +git push origin issue-- + ↓ +gh pr create --title "..." --body "Closes #" --base main + ↓ +gh issue comment --body "✅ PR готов: " +``` + +### Формат ветки + +``` +issue-42-add-daily-limit +issue-57-fix-ci-linux-gtk +issue-103-theme-dark-light +``` + +### Формат коммита + +``` +feat(monitor): add daily limit display (closes #42) +fix(ci): install gtk deps for linux gui build (closes #57) +docs(readme): add demo screenshots (closes #103) +``` + +Типы: `feat` `fix` `docs` `refactor` `test` `ci` `chore` + +--- + +## 3. Ограничения при реализации + +### 3.1 Трогать только то что в Issue + +Если Issue про `src/notify.rs` — не рефакторить `src/monitor.rs` попутно. +Попутные улучшения = отдельный Issue. + +### 3.2 Запрещено без явного указания в Issue + +- Менять `Cargo.toml` (добавлять/удалять зависимости) +- Менять `ci.yml` или `release.yml` +- Переименовывать публичные функции и структуры +- Менять формат JSON-вывода (`--json`) +- Трогать `SUBMISSION.ru.md` +- Коммитить `.env` файлы с ключами + +### 3.3 API-ключи + +Никогда не логировать `VIBEMODE_API_KEY`, `NEUROGATE_API_KEY`. +Если ключ попал в вывод — задача не завершена, нужен отдельный Issue на исправление. + +--- + +## 4. Проверка перед PR + +Все три команды должны завершиться с кодом 0: + +```bash +cargo test --locked +cargo clippy --all-targets -- -D warnings +cargo fmt --check +``` + +Дополнительно для GUI-изменений: + +```bash +cargo build --features gui --locked +``` + +Если тесты красные — **не создавать PR**. Исправить и перепроверить. + +--- + +## 5. Оформление PR + +```markdown +## Что сделано +<одна-две строки что изменилось> + +## Как проверить +<конкретные команды для ручной проверки> + +## Closes +Closes # +``` + +Не писать: "я сделал X, Y, Z, W" — только то что в Issue. + +--- + +## 6. После merge + +```bash +gh issue close --comment "✅ Выполнено в PR #" +git checkout main +git pull origin main +git branch -d issue-- +``` + +--- + +## 7. Если что-то пошло не так + +### Тесты не проходят после реализации + +```bash +gh issue comment --body "⚠️ Тесты не проходят: <вывод ошибки>. Нужна помощь." +``` +Снять assignee. Не пушить сломанный код. + +### Issue оказался больше чем ожидалось + +```bash +gh issue comment --body "⚠️ Задача требует изменений в — это за рамками Issue. Создаю дочерний Issue." +gh issue create --title "..." --body "Дочерняя задача от #" --label agent +``` + +### Конфликт с main + +```bash +git fetch origin main +git rebase origin/main +# решить конфликты +git push --force-with-lease origin issue-- +``` + +--- + +## 8. Labels (система меток) + +| Label | Значение | +|-------|----------| +| `agent` | Готово к выполнению агентом | +| `blocked` | Заблокировано — не брать | +| `needs-review` | Ждёт ревью человека | +| `bug` | Баг | +| `feat` | Новая фича | +| `ci` | CI/CD | +| `docs` | Документация | +| `gui` | Изменения в Slint GUI | +| `tui` | Изменения в ratatui TUI | +| `priority:high` | Сделать в первую очередь | +| `priority:low` | Можно отложить | + +Агент берёт задачи в порядке: `priority:high` → без приоритета → `priority:low`. + +--- + +## 9. Что агент НЕ делает самостоятельно + +- Не создаёт Issue сам (только человек) +- Не закрывает чужие Issue +- Не делает force push в `main` +- Не удаляет файлы без явного указания в Issue +- Не меняет версию в `Cargo.toml` (это делает человек перед релизом) +- Не создаёт теги и релизы + +--- + +## 10. Контекст проекта + +**Репозиторий:** https://github.com/xodapi/vimit +**Язык:** Rust 2024 edition +**Тесты:** `cargo test --locked` (58 тестов) +**GUI:** Slint (`--features gui`) +**TUI:** ratatui +**API:** `GET /v1/me` → `VIBEMODE_API_KEY` +**Платформы:** Windows x86_64, Linux x86_64/aarch64, macOS aarch64 + +Структура `src/`: +``` +src/ + main.rs — точка входа, CLI парсинг + api.rs — запрос /v1/me, модели данных + monitor.rs — TUI ratatui монитор + notify.rs — desktop notifications + cli/ + update.rs — self-update логика + bin/ + vimit-gui.rs — Slint GUI +ui/ + app.slint — главный Slint файл +``` + +--- + +## 11. Быстрый старт для нового агента + +```bash +# 1. Посмотреть открытые задачи +gh issue list --state open --label agent --assignee "" + +# 2. Взять задачу +gh issue edit --add-assignee "@me" +gh issue comment --body "🤖 Начинаю" + +# 3. Создать ветку +git checkout -b issue-- + +# 4. Сделать работу, проверить +cargo test --locked && cargo clippy --all-targets -- -D warnings && cargo fmt --check + +# 5. Коммит и PR +git commit -m "feat(...): ... (closes #)" +git push origin issue-- +gh pr create --fill --base main +``` + +--- + +## 12. Параллельная работа нескольких агентов + +При работе 2+ агентов одновременно — читать **MULTI-AGENT.md**. + +Краткие правила: +- Каждый Issue имеет label `zone:X` — два агента не берут одну зону +- Проверять зависимости (`Зависит от #N`) перед стартом +- Файлы `Cargo.toml`, `ui/app.slint`, `src/main.rs` — только один агент +- При rebase-конфликте в `Cargo.lock` — пересоздать через `cargo generate-lockfile` diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..e958ccf --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,267 @@ +# AGENTS.md — Глобальные правила для AI-агентов + +> Этот файл читают все агенты: Codex, Gemini, Grok, Claude Code, Factory Droid. +> Он описывает **единственный** разрешённый рабочий процесс. +> Нарушение любого пункта = задача не принята. + +--- + +## 0. Главное правило + +**Каждое изменение в коде начинается с GitHub Issue и заканчивается закрытием этого Issue.** + +Агент не пишет код "в воздух". Нет Issue — нет работы. + +--- + +## 1. Перед началом любой задачи + +### 1.1 Найти Issue + +``` +gh issue list --state open --label agent +``` + +Работать только с Issue у которых: +- label `agent` — готово к выполнению агентом +- label `blocked` отсутствует +- assignee пустой или назначен на тебя + +### 1.2 Назначить себя + +``` +gh issue edit --add-assignee "@me" +gh issue comment --body "🤖 Начинаю выполнение. Агент: " +``` + +### 1.3 Прочитать Issue полностью + +Issue содержит: +- **Что сделать** — конкретная задача +- **Критерии приёмки** — как проверить что готово +- **Что нельзя трогать** — файлы и модули вне задачи + +Если чего-то не хватает для выполнения — оставить комментарий с вопросом, +снять assignee, **не начинать код**. + +--- + +## 2. Рабочий процесс (обязательный порядок) + +``` +Issue открыт + ↓ +git checkout -b issue-- + ↓ +Реализация (только в рамках Issue) + ↓ +cargo test --locked # все тесты зелёные +cargo clippy --all-targets -- -D warnings # ноль предупреждений +cargo fmt --check # форматирование чистое + ↓ +git commit -m "fix/feat/docs(): <описание> (closes #)" + ↓ +git push origin issue-- + ↓ +gh pr create --title "..." --body "Closes #" --base main + ↓ +gh issue comment --body "✅ PR готов: " +``` + +### Формат ветки + +``` +issue-42-add-daily-limit +issue-57-fix-ci-linux-gtk +issue-103-theme-dark-light +``` + +### Формат коммита + +``` +feat(monitor): add daily limit display (closes #42) +fix(ci): install gtk deps for linux gui build (closes #57) +docs(readme): add demo screenshots (closes #103) +``` + +Типы: `feat` `fix` `docs` `refactor` `test` `ci` `chore` + +--- + +## 3. Ограничения при реализации + +### 3.1 Трогать только то что в Issue + +Если Issue про `src/notify.rs` — не рефакторить `src/monitor.rs` попутно. +Попутные улучшения = отдельный Issue. + +### 3.2 Запрещено без явного указания в Issue + +- Менять `Cargo.toml` (добавлять/удалять зависимости) +- Менять `ci.yml` или `release.yml` +- Переименовывать публичные функции и структуры +- Менять формат JSON-вывода (`--json`) +- Трогать `SUBMISSION.ru.md` +- Коммитить `.env` файлы с ключами + +### 3.3 API-ключи + +Никогда не логировать `VIBEMODE_API_KEY`, `NEUROGATE_API_KEY`. +Если ключ попал в вывод — задача не завершена, нужен отдельный Issue на исправление. + +--- + +## 4. Проверка перед PR + +Все три команды должны завершиться с кодом 0: + +```bash +cargo test --locked +cargo clippy --all-targets -- -D warnings +cargo fmt --check +``` + +Дополнительно для GUI-изменений: + +```bash +cargo build --features gui --locked +``` + +Если тесты красные — **не создавать PR**. Исправить и перепроверить. + +--- + +## 5. Оформление PR + +```markdown +## Что сделано +<одна-две строки что изменилось> + +## Как проверить +<конкретные команды для ручной проверки> + +## Closes +Closes # +``` + +Не писать: "я сделал X, Y, Z, W" — только то что в Issue. + +--- + +## 6. После merge + +```bash +gh issue close --comment "✅ Выполнено в PR #" +git checkout main +git pull origin main +git branch -d issue-- +``` + +--- + +## 7. Если что-то пошло не так + +### Тесты не проходят после реализации + +```bash +gh issue comment --body "⚠️ Тесты не проходят: <вывод ошибки>. Нужна помощь." +``` +Снять assignee. Не пушить сломанный код. + +### Issue оказался больше чем ожидалось + +```bash +gh issue comment --body "⚠️ Задача требует изменений в — это за рамками Issue. Создаю дочерний Issue." +gh issue create --title "..." --body "Дочерняя задача от #" --label agent +``` + +### Конфликт с main + +```bash +git fetch origin main +git rebase origin/main +# решить конфликты +git push --force-with-lease origin issue-- +``` + +--- + +## 8. Labels (система меток) + +| Label | Значение | +|-------|----------| +| `agent` | Готово к выполнению агентом | +| `blocked` | Заблокировано — не брать | +| `needs-review` | Ждёт ревью человека | +| `bug` | Баг | +| `feat` | Новая фича | +| `ci` | CI/CD | +| `docs` | Документация | +| `gui` | Изменения в Slint GUI | +| `tui` | Изменения в ratatui TUI | +| `priority:high` | Сделать в первую очередь | +| `priority:low` | Можно отложить | + +Агент берёт задачи в порядке: `priority:high` → без приоритета → `priority:low`. + +--- + +## 9. Что агент НЕ делает самостоятельно + +- Не создаёт Issue сам (только человек) +- Не закрывает чужие Issue +- Не делает force push в `main` +- Не удаляет файлы без явного указания в Issue +- Не меняет версию в `Cargo.toml` (это делает человек перед релизом) +- Не создаёт теги и релизы + +--- + +## 10. Контекст проекта + +**Репозиторий:** https://github.com/xodapi/vimit +**Язык:** Rust 2024 edition +**Тесты:** `cargo test --locked` (58 тестов) +**GUI:** Slint (`--features gui`) +**TUI:** ratatui +**API:** `GET /v1/me` → `VIBEMODE_API_KEY` +**Платформы:** Windows x86_64, Linux x86_64/aarch64, macOS aarch64 + +Структура `src/`: +``` +src/ + main.rs — точка входа, CLI парсинг + api.rs — запрос /v1/me, модели данных + monitor.rs — TUI ratatui монитор + notify.rs — desktop notifications + cli/ + update.rs — self-update логика + bin/ + vimit-gui.rs — Slint GUI +ui/ + app.slint — главный Slint файл +``` + +--- + +## 11. Быстрый старт для нового агента + +```bash +# 1. Посмотреть открытые задачи +gh issue list --state open --label agent --assignee "" + +# 2. Взять задачу +gh issue edit --add-assignee "@me" +gh issue comment --body "🤖 Начинаю" + +# 3. Создать ветку +git checkout -b issue-- + +# 4. Сделать работу, проверить +cargo test --locked && cargo clippy --all-targets -- -D warnings && cargo fmt --check + +# 5. Коммит и PR +git commit -m "feat(...): ... (closes #)" +git push origin issue-- +gh pr create --fill --base main +``` diff --git a/docs/MULTI-AGENT.md b/docs/MULTI-AGENT.md new file mode 100644 index 0000000..4749b4f --- /dev/null +++ b/docs/MULTI-AGENT.md @@ -0,0 +1,176 @@ +# MULTI-AGENT.md — Координация нескольких агентов + +> Дополнение к AGENTS.md для случаев когда 2+ агента работают параллельно. +> Читать вместе с AGENTS.md. + +--- + +## Проблема параллельной работы + +Когда два агента берут разные Issue одновременно — они могут: +- Редактировать один и тот же файл → merge conflict +- Зависеть друг от друга → deadlock +- Оба добавить зависимость в Cargo.toml → конфликт lock-файла + +Решение — **файловые зоны** и **явные зависимости между Issue**. + +--- + +## 1. Файловые зоны (кто что трогает) + +Каждый Issue получает label зоны. Два агента не берут Issue из одной зоны одновременно. + +| Label зоны | Файлы | +|------------|-------| +| `zone:api` | `src/api.rs`, `src/cli/update.rs` | +| `zone:tui` | `src/monitor.rs`, `src/main.rs` | +| `zone:notify` | `src/notify.rs` | +| `zone:gui` | `ui/*.slint`, `src/bin/vimit-gui.rs` | +| `zone:ci` | `.github/workflows/*.yml` | +| `zone:docs` | `README*.md`, `SUBMISSION*.md`, `ROADMAP.md` | +| `zone:cargo` | `Cargo.toml`, `Cargo.lock`, `build.rs` | + +### Правило зон + +Перед тем как взять Issue — проверить: +```bash +gh issue list --state open --label "zone:gui" --assignee "*" +``` +Если кто-то уже работает в этой зоне — **не брать** Issue из той же зоны. +Взять Issue из другой зоны или подождать. + +--- + +## 2. Зависимости между Issue + +В теле Issue может быть секция: +``` +## Зависит от +- #12 (должен быть merged перед началом) +- #15 (должен быть merged перед началом) +``` + +Агент **не начинает** Issue если его зависимости не закрыты. +Проверить: +```bash +gh issue view | grep "Зависит от" +gh issue view --json state | jq .state +``` +Если зависимость открыта — оставить комментарий и не брать задачу: +```bash +gh issue comment \ + --body "⏳ Ожидаю закрытия #. Вернусь после merge." +``` + +--- + +## 3. Алгоритм выбора задачи для агента + +``` +1. Получить список свободных Issues: + gh issue list --state open --label agent --assignee "" + +2. Для каждого Issue (по приоритету): + a. Проверить зависимости → все closed? + b. Определить zone: label + c. Проверить что zone свободна: + gh issue list --label "zone:" --assignee "*" --state open + d. Если зона свободна И зависимости closed → взять + +3. Назначить себя + оставить комментарий с именем агента +``` + +--- + +## 4. Опасные файлы — только один агент + +Эти файлы **никогда не редактируются параллельно**: + +| Файл | Почему опасен | +|------|--------------| +| `Cargo.toml` | Конфликт зависимостей + Cargo.lock | +| `Cargo.lock` | Автогенерируется, всегда конфликт | +| `src/main.rs` | Точка входа, много агентов → chaos | +| `ui/app.slint` | Монолитный UI файл | +| `.github/workflows/ci.yml` | CI логика | + +Для этих файлов действует правило: **label `zone:cargo` / `zone:ci` / `zone:gui` — не более одного активного Issue одновременно**. + +--- + +## 5. Rebase-стратегия для параллельных веток + +Когда твой PR готов, но в main уже появились новые коммиты от другого агента: + +```bash +git fetch origin main +git rebase origin/main + +# Если конфликт в Cargo.lock — не решать вручную: +cargo generate-lockfile +git add Cargo.lock +git rebase --continue + +# Если конфликт в src/ файле: +# Прочитать оба изменения, применить оба, не терять чужую работу +git rebase --continue + +git push --force-with-lease origin issue-- +``` + +--- + +## 6. Коммуникация через Issue + +Если агент видит что его работа влияет на чужой открытый Issue: + +```bash +gh issue comment <ЧУЖОЙ_ISSUE> \ + --body "ℹ️ Heads up: мой PR # меняет <файл/функцию> которую ты используешь. + Merge планируется сегодня. Учти при rebase." +``` + +--- + +## 7. Пример параллельной работы (без конфликтов) + +``` +Агент A берёт #57 (zone:ci) → трогает только .github/workflows/ +Агент B берёт #42 (zone:gui) → трогает только ui/*.slint +Агент C берёт #103 (zone:docs) → трогает только README*.md + +✅ Нет пересечений → merge без конфликтов +``` + +``` +Агент A берёт #57 (zone:ci) +Агент B хочет #44 (zone:ci) → СТОП: зона занята агентом A +Агент B берёт #90 (zone:tui) → OK: другая зона + +✅ B выбрал свободную зону +``` + +--- + +## 8. Чеклист перед взятием задачи (multi-agent) + +``` +[ ] Issue имеет label `agent` +[ ] assignee пустой +[ ] Зависимости (если есть) — все closed +[ ] Зона файлов — нет активного Issue в той же зоне +[ ] git pull origin main (актуальная база) +[ ] Создать ветку от свежего main +``` + +--- + +## 9. Человек-координатор + +Человек (синтографист) управляет приоритетами через: +- Назначение labels `priority:high` +- Добавление/снятие label `blocked` +- Закрытие Issues которые стали неактуальными +- Слияние PR в main + +Агент не принимает решения о приоритетах — только выполняет. diff --git a/docs/deep-research-report.md b/docs/deep-research-report.md new file mode 100644 index 0000000..a45f47c --- /dev/null +++ b/docs/deep-research-report.md @@ -0,0 +1,147 @@ +# Аналитический отчёт по функциям и болям пользователей проекта vimit + +## Executive summary + +`vimit` уже решает понятную и острую задачу: локально показывает расход квоты VibeMode по окнам 5h / 24h / 7d / 30d, умеет работать как CLI, TUI и опциональный GUI, поддерживает demo/mock, диагностику, интерактивную настройку, несколько аккаунтов, desktop-уведомления, тренды и автообновление. При этом продукт очень молод: публичные релизы выходили серией с 21 по 27 июня 2026 года, а в период между `v0.6.0` и `v0.6.1` проект даже переименовался из `neurogate-limit-watch` в `vimit`; в репозитории на момент анализа видно 15 issues, 0 открытых PR, 2 закрытых PR и одну discussion-тему с 0 комментариев. Это говорит о высокой скорости итераций, но и о незакрытом базовом UX-долге. citeturn41view0turn46view0turn46view1turn27view0turn45view0 + +Главная ценность проекта сегодня — не в нехватке функций, а в том, что текущие функции уже хороши, но местами не доведены до “безошибочного первого опыта”. Самые заметные проблемы: путаница в именах ключей и брендинге (`NEUROGATE_API_KEY` против `VIBEMODE_API_KEY`), неоднозначный путь к `.env` и конфигу для Windows-установки, слабая визуальная документация для новичка, недораскрытые возможности TUI, а также не до конца оформленная история приватности: проект заявляет local-first и “no telemetry”, но для пользователя это не собрано в единый понятный UX-слой доверия. citeturn36view0turn38view2turn14view1turn24view0turn38view5turn42view0 + +Если выбирать, что даст наибольший эффект в ближайшие недели, то приоритеты такие: сначала устранить несогласованность онбординга и документации, затем сделать “первый запуск без боли” для Windows/GUI, после этого усилить multi-account UX и видимость ключевых команд в TUI. Уже после этого имеет смысл инвестировать в более длинные темы: privacy center, IDE overlay/MCP и ресурсные guard-rail’ы для weak machines. Это даст рост не только удобства, но и доверия, а также снизит вероятность ложного отказа на самом первом запуске — критичная точка для молодой утилиты без широкой внешней воронки. citeturn43view0turn43view2turn18view0turn18view1turn26view1turn26view0 + +Коротко: у `vimit` уже есть сильный функциональный фундамент, но продукту нужен не “ещё один экран”, а выравнивание базового UX-контракта. Лучшие инвестиции сейчас — это консистентность, first-run flow, поясняющие состояния и прозрачность приватности. citeturn41view4turn36view1turn13view1turn14view0 + +## Исходные данные и текущее состояние продукта + +В рамках анализа были просмотрены: русскоязычный README, ROADMAP, SECURITY/AUDIT, releases, discussion, issues, код CLI/TUI/GUI, а также сценарии `init`, `doctor`, `update` и Windows-скрипты установки/удаления. Отдельный `CHANGELOG.md` в просмотренных источниках не указан; фактическую историю изменений лучше считать по GitHub Releases. citeturn36view3turn36view2turn36view0turn36view1turn46view0turn45view0turn13view0turn13view1turn13view2turn24view0turn24view1 + +По сути продукта `vimit` — это нативная Rust-утилита для мониторинга квот VibeMode без Python/Node/SDK, с human/JSON/compact-выводом, полноэкранным ratatui-монитором, темами, оповещениями, multi-account, 30-дневными трендами и demo/mock режимами. В `Cargo.toml` продукт определён как “Safe VibeMode quota monitor for Codex/Droid workflows”, а релиз `v0.6.2` дополнительно подчёркивает single native binary, TUI-предустановки, пер-оконные пороги и Slint GUI. citeturn10view0turn41view0turn46view0 + +Сильная сторона проекта — уже сейчас есть три слоя интерфейса под разные сценарии: CLI для скриптов и CI, TUI для живого наблюдения, GUI для desktop-использования. В TUI есть `full/compact/mini`, спарклайны, темы и управление панелями; в GUI есть системный трей, скрытый режим, dropdown аккаунтов, ручная и авто-проверка обновлений, светлая/тёмная тема и панель настроек. В CLI есть интерактивный мастер `--init`, диагностика `--doctor`, а также self-update с кешированием проверки на 24 часа. citeturn41view4turn18view0turn18view1turn17view0turn13view0turn13view1turn13view2turn14view5 + +Но из этих же источников видно, что продукт ещё “сырой” именно как пользовательский путь. В ROADMAP и Discussion повторяются темы компактного виджета, Termux/Android, уведомлений, пер-оконных порогов и примеров реальных workflow. В issues отдельно поднимаются скриншоты README, автообнаружение ключей, self-update, дневной лимит, MCP, ресурсные ограничения и PII masking. Это хороший сигнал: команда уже интуитивно видит правильные направления, но часть из них пока существует как backlog, а не как завершённый пользовательский опыт. citeturn37view3turn45view0turn43view0turn43view2turn43view1turn25view0turn26view1turn26view0turn26view2 + +Самая важная находка анализа — консистентность ещё не доведена до продуктового стандарта. В `SECURITY.md` фигурирует `NEUROGATE_API_KEY`, в README примеры уже ведутся через `VIBEMODE_API_KEY`, а код `init` и `main` подсказывает именно `VIBEMODE_API_KEY`; при этом релизная история показывает недавнее переименование проекта. Для пользователя это выглядит как возможная “ложная ошибка настройки”, даже если сама логика кода в порядке. citeturn36view0turn38view2turn10view1turn14view1turn46view1 + +Ещё один критический UX-риск — Windows installer. PowerShell-скрипт создаёт `.env` в `%USERPROFILE%\.vimit\.env`, тогда как README объясняет поиск `.env` через `--env-file`, текущую директорию и папку рядом с бинарником; явного пользовательского описания чтения `%USERPROFILE%\.vimit\.env` в публичной документации нет. Это не обязательно логическая ошибка, но уже точно продуктовая неоднозначность, которая способна ломать “установил → запустил → не заработало”. citeturn24view0turn38view5 + +## Пользовательские персоны и реальные боли + +Ниже — пять ключевых персон, которых сам проект уже частично адресует, и соответствующие точки фрустрации. + +| Персона | Что уже покрыто | Главная боль | Почему это боль сейчас | +|---|---|---|---| +| **Novice Windows user** | demo, `--init`, `--doctor`, PowerShell install/uninstall, helper `.cmd`, GUI/трей | Первый запуск неоднозначен: где ключ, где `.env`, что запускать — `vimit.exe` или GUI, почему после двойного клика просто консоль | README просит CLI-команды и `.env` рядом с бинарником, installer пишет `.env` в другой путь, README пока без скриншотов, а issue на скриншоты помечен high priority. citeturn38view5turn24view0turn43view0 | +| **Developer / CLI user** | JSON/compact, TUI presets, `--fail-on`, thresholds, `--doctor`, trend storage | Мощность есть, но discoverability неполная: часть важных функций скрыта за клавишами и режимами | README отдельно объясняет, что надо нажать `5` для трендов, TUI по умолчанию держит `show_trends = false`, а AUDIT прямо отмечает недостаток help/discoverability в monitoring output. citeturn41view4turn33view2turn42view0turn42view4 | +| **Privacy-conscious user** | заявлены local-first, no telemetry, only `/v1/me`, stealth mode в GUI | Нет единого “центра доверия”: что именно уходит в сеть, что скрывает stealth, что попадает в notifications/JSON | README и SECURITY обещают высокий privacy baseline, но open issue про PII masking показывает, что расширенная модель приватности ещё не реализована; stealth mode пока скрывает суммы в GUI, а не всю информационную поверхность. citeturn36view0turn41view4turn17view0turn26view2 | +| **Multi-account user** | `accounts.toml`, Tab в TUI, dropdown в GUI | В TUI переключение аккаунтов линейное и слабо обозримое; нет быстрых сравнений аккаунтов | README обещает multi-account, GUI даёт dropdown, но TUI использует только последовательный `Tab`, а футер показывает лишь подсказку `Tab account`. citeturn38view4turn18view0turn31view0turn34view0 | +| **Power user / IDE overlay user** | compact output, tray/tooltip, TUI, идеи status-bar/widget, backlog MCP | Нет “always visible” слоя внутри IDE/overlays; проект всё ещё живёт как отдельное окно/терминал | В discussion и roadmap повторяются status bar / widget / Termux widget сценарии, а issue #12 описывает MCP-интеграцию как отсутствующую, но желанную. citeturn45view0turn37view3turn26view1 | + +Из этих персон самая недообслуженная сегодня — **novice Windows user**. Не потому, что Windows не поддержан, а потому что именно здесь накладываются сразу четыре источника трения: CLI-центричная документация, отсутствие скриншотов, недавний rename-переход и спорная история с путями `.env`. Для молодой утилиты это главный барьер конверсии “скачал из релизов → остался пользоваться”. citeturn38view5turn43view0turn46view1turn24view0 + +Следом идёт **developer/CLI user**. Здесь проблема не в функциональном дефиците, а в “скрытой мощности”: продукт уже умеет много, но не всегда подсказывает это вовремя. Когда полезные функции включаются по клавишам, комментариям в README или по памяти, это работает для автора инструмента, но хуже работает для пользователя “после двух недель перерыва”. citeturn41view4turn33view2turn34view0turn42view0 + +## Рекомендации по функциям и UX-решениям + +Ниже — приоритетный список улучшений. Приоритет и сложность — экспертная оценка по текущему коду, текущим backlog-сигналам и масштабу затронутых поверхностей. + +| Рекомендация | Кого решает | UX-решение | Priority | Сложность | KPI | +|---|---|---|---|---|---| +| **Единый first-run onboarding для Windows и GUI** | novice Windows | При первом запуске показывать wizard: “Проверить demo → ввести ключ → протестировать соединение → выбрать режим интерфейса”. В GUI — стартовая страница вместо пустого/ошибочного состояния. Основание: уже есть `--init`, `--doctor`, GUI settings, update panel. citeturn13view0turn13view1turn18view0 | High | Medium | First successful setup rate, доля пользователей, дошедших до первого успешного `OK (4 window(s))`, снижение abandon rate после первого запуска | +| **Убрать naming/config debt** | novice Windows, developer | Полностью унифицировать `VIBEMODE_*` против `NEUROGATE_*`, пути `.env`, название проекта и текст ошибок. Добавить одну canonical-страницу “Где vimit ищет ключ”. Основание: README, SECURITY, release rename, installer path расходятся. citeturn36view0turn38view2turn24view0turn46view1 | High | Low | Снижение doctor-ошибок “key not found”, меньше GitHub issues по установке, рост доли успешных запусков без ручного чтения документации | +| **Скриншоты и “выбор интерфейса” в README/releases** | novice Windows, power user | Закрыть issue #22 и добавить в README блок “Что выбрать: CLI / TUI / GUI”. В release asset/README: 1 TUI screenshot, 1 GUI screenshot, 1 таблица “для кого что”. citeturn43view0turn41view3 | High | Low | CTR с README/release на первый запуск, снижение времени до первого полезного действия, меньше вопросов “а как это выглядит?” | +| **Страница ошибок и health center** | novice Windows, developer | Вместо сухих ошибок — error page с действиями: “Открыть `.env`”, “Запустить диагностику”, “Проверить сеть/API”, “Запустить demo”. Для CLI — короткий код ошибки + hint + команда-фикс. Основание: в коде уже есть error hints, `doctor` и `init`. citeturn10view1turn13view1turn14view0 | High | Medium | Успешный self-recovery rate, снижение повторных запусков с той же ошибкой, снижение issue rate на setup/network | +| **Account Hub и быстрый switcher** | multi-account | В GUI: mini summary по всем аккаунтам в dropdown. В TUI: popup по `a`/`Tab`, где видно alias + peak% + reset. Запоминать последний выбранный аккаунт. Основание: сейчас GUI лучше TUI по account UX. citeturn18view0turn31view0turn34view0 | High | Medium | Частота использования multi-account, снижение времени до переключения аккаунта, доля пользователей с 2+ аккаунтами, которые реально переключаются | +| **Повысить discoverability TUI** | developer, power user | Постоянная action-ribbon: `? help`, `5 trends`, `Tab account`, `p route`, `r refresh`. Пустые состояния должны подсказывать, что именно включить. Основание: тренды существуют, но скрыты; help есть, но видимость ограничена. citeturn33view2turn34view0turn42view0 | Medium | Low | Использование трендов, help open rate, retention у TUI-пользователей, снижение “feature invisibility” в пользовательских отзывах | +| **Daily budget planner** | developer, power user | Сделать “Сегодня” полноценным first-class объектом и в GUI, и в TUI: recommended/day, spent today, burn pace, прогноз “хватит ли до reset”. Основание: issue #21 уже формулирует почти готовый UX. citeturn25view0turn35view0 | High | Medium | Доля пользователей, настроивших daily limit, число дней без превышения weekly budget, engagement с daily card | +| **Privacy Center и полнофункциональный stealth** | privacy-conscious | Единая вкладка: “Что читает vimit”, “Что отправляет vimit”, “Что хранится локально”, “Что скрывает Stealth Mode”. Распространить stealth на notifications/tray/overlay. Если развивать proxy/MCP-направление — делать redaction как отдельный режим доверия, а не как второстепенную галочку. citeturn36view0turn41view4turn17view0turn26view2 | Medium | Medium | Включаемость stealth, снижение privacy-concern issues, trust score в опросе “понимаю, какие данные уходят” | +| **IDE overlay и MCP-интеграция** | power user / IDE overlay | Две ступени: сначала menubar/always-on-top mini overlay, затем `--mcp` для IDE discovery. Это даст “квота всегда перед глазами” без отдельного терминала. Основание: status-bar/widget ideas в discussion/roadmap и issue о MCP. citeturn45view0turn37view3turn26view1 | Medium | High | DAU overlay/MCP, среднее время открытия отдельного TUI/GUI, доля пользователей, использующих vimit внутри IDE workflow | +| **Ресурсные guard-rail’ы в watch/monitor** | novice Windows, power user | Degraded mode при перегрузе: увеличивать интервал, выключать тяжёлые панели, показывать badge “degraded”. Особенно важно для старых Windows-машин и IDE-heavy workflow. Основание: issue #13 довольно точно описывает нужный режим. citeturn26view0 | Medium | Medium | Средний CPU/RAM footprint, crash/freezing rate, session length в `--watch`/`--monitor` | + +Текущий путь первого запуска логически уже существует, но он распределён по нескольким поверхностям — README, installer, `--init`, `--doctor`, ручные команды. Для novice-пользователя это нужно собрать в один UX-поток. citeturn38view2turn24view0turn13view1 + +```mermaid +flowchart TD + A[Скачал релиз] --> B[Распаковал архив] + B --> C{Что запускать?} + C -->|CLI| D[vimit --demo или vimit] + C -->|GUI| E[Неочевидно в публичном потоке] + D --> F{Ключ найден?} + F -->|Нет| G[Читать README или запускать --init/--doctor] + F -->|Да| H[Получить статус лимитов] +``` + +Целевой поток должен быть короче и не требовать от пользователя знания внутренних терминов проекта. + +```mermaid +flowchart TD + A[Первый запуск vimit] --> B[Экран выбора: Demo / Подключить ключ] + B --> C[Wizard настройки] + C --> D[Проверка ключа и сети] + D --> E[Выбор интерфейса: GUI / TUI / CLI] + E --> F[Первый успешный статус] + F --> G[Подсказка: уведомления, multi-account, daily budget] +``` + +## Риски, privacy и безопасность + +С точки зрения позиции доверия у проекта хорошая база: в `SECURITY.md` и `README.ru.md` зафиксированы локальность, отсутствие телеметрии, работа с ключом через env и то, что в normal mode сетевой вызов сводится к `GET /v1/me`. AUDIT также повторяет, что JSON не должен включать account identity, а `--with-abtop` использует privacy-safe summaries. Это сильный фундамент для persona “privacy-conscious user”. citeturn36view0turn41view4turn42view0turn42view2 + +Но именно из-за сильных privacy-обещаний любая неоднозначность становится опаснее. Если продукт обещает “ключ не пишется на диск”, а пользователь одновременно видит installer, который создаёт конфиг сам, плюс разные имена env-переменных в документах, это снижает доверие — даже если реальной утечки нет. Здесь риск не столько технический, сколько продуктовый: пользователь перестаёт понимать модель безопасности. citeturn36view0turn24view0turn38view2 + +| Риск | Где проявляется | Последствие | Смягчение | +|---|---|---|---| +| **Путаница env-ключей и брендинга** | README / SECURITY / rename-история | Ошибки первого запуска, недоверие к документации | Один canonical namespace, migration-note в README/releases, doctor с явным указанием “ожидается именно этот ключ”. citeturn36view0turn38view2turn46view1 | +| **Неочевидный путь конфигурации в Windows** | installer vs README | “Установил, но не работает” | Привести installer и runtime search order к одному сценарию; в wizard показать реальный путь конфигурации. citeturn24view0turn38view5 | +| **Stealth Mode закрывает не все поверхности** | GUI/tray/notifications | Privacy gap при screen sharing | Сделать stealth глобальным режимом отображения, включая tray, popup и overlay. citeturn17view0turn41view4 | +| **Auto-discovery ключей может стать небезопасным по умолчанию** | issue #11 | Ложный выбор ключа, трудно объяснимая магия | Делать discovery opt-in или first-run only; всегда показывать, какой источник выбран, без вывода секрета. citeturn43view2 | +| **Развитие proxy/MCP без privacy-design** | issues #10, #12, #15 | Расширение поверхности риска раньше UX-доверия | Сначала Privacy Center, auditability и redaction policy; только затем глубокие интеграции. citeturn26view1turn26view2turn26view3 | +| **In-place self-update без понятного rollback UX** | `update.rs`, GUI updates | Страх обновления в корпоративной среде | Показать current → target version, changelog summary, ручной rollback/help link, checksum/status page. citeturn13view2turn18view1turn43view1 | + +Практический вывод здесь такой: у `vimit` не проблема “безопасность не продумана”; наоборот, она продумана лучше среднего для маленькой утилиты. Проблема в том, что этот security story пока недостаточно хорошо упакован для обычного пользователя. citeturn36view0turn42view0turn42view1 + +## Метрики успеха и roadmap + +Чтобы не распыляться, я бы измерял успех не “по числу фич”, а по трём воронкам: **успешный первый запуск**, **переход в регулярное использование**, **доверие и предсказуемость**. Для этого достаточно простых KPI, которые можно собирать даже без телеметрии: через opt-in crash/error report, ручные опросы, GitHub issue labels, и локальные события в явном debug/export-режиме. Если телеметрия принципиально не используется, это должно быть прямо отражено: часть KPI придётся измерять “беднее”, но честно. citeturn36view0turn42view0 + +### Быстрые wins + +| Горизонт | Что делать | Почему это быстро | +|---|---|---| +| **1–2 дня** | Закрыть naming debt: README, SECURITY, doctor example, error copy, release notes | Изменения в текстах и хинтах, без тяжёлой архитектуры. citeturn36view0turn38view2turn10view1 | +| **1–2 дня** | Закрыть issue со скриншотами и вставить “CLI / TUI / GUI — что выбрать” | Уже сформулировано как отдельный high-priority issue. citeturn43view0 | +| **2–3 дня** | Исправить Windows install path contract | Нужно синхронизировать install script и docs. citeturn24view0turn38view5 | +| **2–4 дня** | Добавить TUI ribbon и пустые состояния для трендов/help/account | Поверхностное изменение TUI, без смены модели данных. citeturn33view2turn34view0 | +| **3–5 дней** | GUI first-run error page с кнопками “Demo / Test connection / Open config / Doctor” | Логика уже есть в `init`, `doctor`, update/settings. citeturn13view0turn13view1turn18view0 | +| **4–7 дней** | Daily budget planner в TUI/JSON и затем в GUI | Issue #21 уже содержит хорошую спецификацию. citeturn25view0turn35view0 | + +### Долгосрочные функции + +| Горизонт | Что делать | Ожидаемый эффект | +|---|---|---| +| **До 3 месяцев** | Account Hub и быстрый switcher | Сильный рост удобства для heavy users и команд с несколькими ключами | +| **До 3 месяцев** | Privacy Center и глобальный stealth | Рост доверия и лучшее позиционирование local-first | +| **До 3 месяцев** | Overlay/menubar mini mode | Уменьшение необходимости держать отдельный терминал | +| **До 3 месяцев** | MCP/IDE integration | Выход в power-user сценарии Cursor/Codex/Windsurf | +| **До 3 месяцев** | Resource self-throttle/degraded mode | Стабильность на слабых машинах и в `--watch`/`--monitor` | + +### Роадмап на 7 дней + +| День | Фокус | Результат | +|---|---|---| +| **День 1** | Аудит терминов и env names | Один canonical namespace во всех docs и errors | +| **День 2** | README/release UX | Скриншоты, matrix “какой интерфейс выбрать”, блок first-run | +| **День 3** | Windows install contract | Installer кладёт конфиг туда, где приложение его реально ожидает, либо создаёт `config.toml` с `env_file` | +| **День 4** | TUI discoverability | Нижняя ribbon, CTA для трендов, явная account/help подсказка | +| **День 5** | GUI first-run health page | Самоисцеление без похода в README | +| **День 6** | Daily budget planner v1 | “Сегодня” в TUI/JSON | +| **День 7** | Финальная полировка | Smoke test Windows/Linux/macOS, обновление release notes | + +### Роадмап на 3 месяца + +| Месяц | Фокус | Выход | +|---|---|---| +| **Первый месяц** | Онбординг, аккаунты, TUI/GUI consistency | “Первый запуск без боли”, multi-account UX v2 | +| **Второй месяц** | Privacy и устойчивость | Privacy Center, глобальный stealth, resource guard | +| **Третий месяц** | Power-user expansion | Overlay/menubar, MCP beta, IDE-oriented workflows | + +Итоговая ставка простая: **сначала снять friction в базовом UX, потом наращивать advanced-функции**. Для `vimit` это особенно важно, потому что core value уже доказан кодом и релизами; сейчас выигрывает не тот backlog, который “самый технологичный”, а тот, который сильнее всего повышает вероятность, что пользователь дойдёт до полезного статуса лимитов за первые 3–5 минут. citeturn46view0turn13view1turn41view4turn42view1 \ No newline at end of file diff --git a/docs/kimi 2.6 audit.md b/docs/kimi 2.6 audit.md new file mode 100644 index 0000000..e22d671 --- /dev/null +++ b/docs/kimi 2.6 audit.md @@ -0,0 +1,147 @@ +Критический анализ кодовой базы vimit +Ниже разбор по категориям: от критических ошибок компиляции до архитектурных проблем. + +1. Критические ошибки (проект не собирается) +src/cli/monitor.rs:497 — mismatched types + +rust + +let mut final_style = pal.bold_level_style(level); +// ... +final_style = pal.danger; // Ошибка: ожидается Style, найден Color +pal.danger имеет тип Color, а final_style — Style. Исправление: pal.danger.into(). + +src/cli/monitor.rs:1489 — missing field offline_duration_min + +rust + +StatusSnapshot { stale: false, windows: ..., daily: None, ... } +В тестовой фабрике test_snapshot() не заполнено поле offline_duration_min, добавленное позже в структуру, но не в тесты. + +Результат: cargo test --locked и cargo clippy падают с ошибкой компиляции. + +2. Логические ошибки / баги +2.1 Recovery-уведомления никогда не отправляются +Файл: src/cli/notify.rs, метод check_windows + +rust + +if let Some(msg) = next_notification(&mut self.last_levels, window) { ... } +if let Some(msg) = recovery_notification(&mut self.last_levels, window) { ... } +next_notification сначала записывает текущий уровень в last_levels. После этого recovery_notification читает уже обновленное значение, поэтому previous == current == Ok, и условие previous == AlertLevel::Ok всегда срабатывает, возвращая None. Recovery-нотификации мертвы. + +2.2 Overlay-режим не работает на Linux / macOS +Файл: src/main.rs + +rust + +let gui_path = current.with_file_name("vimit-gui.exe"); +На Unix-системах бинарь называется vimit-gui без .exe. Overlay просто не запустится. + +2.3 Дублирование и рассинхрон логики daily-окна +Файл: src/cli/monitor.rs, collect_status + +При успешном fetch: + +вызывается daily_file.update(c.remaining) + daily_file.save() +затем вставляется "today" window +При fallback на кэш (ветка Err(error)): + +daily_file.update() не вызывается +"today" window вставляется через get_state, но расход за день не обновляется +Это приводит к тому, что в offline-режиме daily-метрика показывает устаревшие или некорректные данные. + +2.4 Race condition при записи daily.toml +Файл: src/cli/daily.rs + +DailyFile::save() делает простое fs::write. Если vimit запущен одновременно из нескольких терминалов или в monitor + вручную, файл может повредиться. + +3. Архитектурные проблемы +3.1 God-object lib.rs +Файл src/lib.rs (~800+ строк кода) смешивает: + +HTTP-клиент и retry-логику +Парсинг 5 разных схем JSON +Форматирование строк и цветов +Работу с .env +Выполнение внешних процессов (abtop) +Глобальный mutable state OFFLINE_SINCE +Это нарушает SRP и затрудняет юнит-тестирование. + +3.2 God-object monitor.rs +src/cli/monitor.rs (~1500 строк) объединяет: + +Event loop TUI +Рендеринг 3 пресетов +Сбор данных (collect_status) +Хелперы для plain-text тестов +Управление панелями +3.3 Глобальный mutable state +rust + +pub static OFFLINE_SINCE: Mutex> = Mutex::new(None); +Делает параллельные тесты невозможными без cargo test -- --test-threads=1 +При мониторинге нескольких аккаунтов один неудачный запрос помечает всё приложение "offline" +3.4 Массовое дублирование в vimit-gui.rs +GUI-бинарь полностью дублирует: + +Логику загрузки accounts.toml (load_gui_accounts вместо AccountsConfig::load) +Поиск .env / dirs_or_default +Конфигурацию HTTP и Router +Обработку demo/live режимов +Любое изменение в CLI-логике требует ручной синхронизации с GUI. + +4. Проблемы безопасности +4.1 PowerShell injection в уведомлениях +Файл: src/cli/notify.rs + +rust + +fn powershell_quote(text: &str) -> String { + text.replace('\'', "''").replace(['\r', '\n'], " ") +} +Экранируется только одиночная кавычка. Спецсимволы PowerShell ($, `, ;, |, &) не обрабатываются. Хотя входные данные приходят из API, вредоносный ответ или скомпрометированный API может выполнить код на машине пользователя. + +4.2 API-ключ в открытом виде в кэше +Файл: src/cli/cache.rs + +rust + +fn cache_key(api_key: &str, api_base: &str) -> String { + format!("{}|{}", api_key, api_base) +} +Ключ API хранится в открытом виде как ключ записи в redb. Файл cache.redb лежит в APPDATA / ~/.config и может быть доступен другим пользователям системы. + +5. Проблемы производительности +5.1 Спам потоками в GUI +Файл: src/bin/vimit-gui.rs, start_refresh + +rust + +fn start_refresh(...) { + thread::spawn(move || { ... }); +} +Каждый клик "Refresh" или изменение настроек порождает новый поток без throttle / debounce / семафора. При частых кликах возможен спам запросами. + +5.2 Полное сканирование trend-базы +Файл: src/cli/trends.rs, query_trends + +rust + +let range = table.range(start_ts..=end_ts)?; +for result in range { ... serde_json::from_slice(...) ... } +Все записи за N дней десериализуются целиком. При частых снапшотах (каждые 5 сек в monitor) объем данных растет быстро, а тренды запрашиваются редко. + +6. Мелкие, но важные замечания +Проблема Где Почему важно +cargo fmt --check может не пройти lib.rs, monitor.rs Много ручного форматирования, нестандартные отступы +Смешение языков vimit-gui.rs (русский), lib.rs (русский), output.rs (английский) Неконсистентный UX +#[allow(clippy::collapsible_if)] в lib.rs и main.rs глобально Подавляет полезные линты без причины +bin/test.rs src/bin/test.rs Бесполезный бинарь, дублирует demo_payload +windows_console_process_count main.rs unsafe FFI без обработки ошибок (GetConsoleProcessList может вернуть 0) +Итоговая сводка +Хочешь, чтобы я исправил критические ошибки компиляции и основные логические баги? + + +Thought for 3s +Анализ завершен. Если нужно исправить найденные ошибки, дай знать. \ No newline at end of file diff --git a/docs/setup-issues.sh b/docs/setup-issues.sh new file mode 100644 index 0000000..978d2a7 --- /dev/null +++ b/docs/setup-issues.sh @@ -0,0 +1,246 @@ +#!/bin/bash +# setup-issues.sh +# Создаёт стартовый набор Issues для vimit с правильными labels +# Запускать один раз: bash setup-issues.sh + +set -e + +REPO="xodapi/vimit" + +echo "=== Создание labels ===" + +labels=( + "agent:0075ca:Готово к выполнению агентом" + "blocked:e11d48:Заблокировано" + "gui:7c3aed:Изменения в Slint GUI" + "tui:0891b2:Изменения в ratatui TUI" + "priority:high:dc2626:Высокий приоритет" + "priority:low:6b7280:Низкий приоритет" +) + +for label_def in "${labels[@]}"; do + IFS=':' read -r name color desc <<< "$label_def" + gh label create "$name" --color "$color" --description "$desc" --repo "$REPO" 2>/dev/null || \ + gh label edit "$name" --color "$color" --description "$desc" --repo "$REPO" 2>/dev/null || true +done + +echo "=== Labels созданы ===" + +echo "=== Создание Issues ===" + +# Issue 1: CI fix (самый срочный) +gh issue create \ + --repo "$REPO" \ + --title "fix(ci): install gtk deps for linux, split gui feature matrix" \ + --label "ci,agent,priority:high" \ + --body "## Что сделать + +В \`.github/workflows/ci.yml\` исправить провал Linux-сборки с \`--features gui\`. + +## Конкретные изменения + +1. В job \`check\` для \`ubuntu-latest\` добавить шаг перед \`cargo check\`: +\`\`\`yaml +- name: Install Linux system deps + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get update && sudo apt-get install -y libglib2.0-dev libgtk-3-dev +\`\`\` + +2. Либо (проще): для \`ubuntu-latest\` убрать \`--features gui\` из clippy и test — GUI-фичу проверять только на Windows/macOS. + +3. В README.md и README.ru.md заменить бадж \`rust-2021\` на \`rust-2024\`. + +## Критерии приёмки + +- CI #48+ проходит зелёным на всех трёх ОС +- \`cargo test --locked\` локально чистый +- Бадж в README обновлён + +## Что нельзя трогать + +- \`src/\` — никаких изменений кода +- \`SUBMISSION.ru.md\`" + +# Issue 2: Theme system в Slint +gh issue create \ + --repo "$REPO" \ + --title "feat(gui): add theme.slint with dark/light mode support" \ + --label "feat,gui,agent" \ + --body "## Что сделать + +Создать \`ui/theme.slint\` с глобальной цветовой системой и подключить в \`ui/app.slint\`. + +## Конкретные изменения + +1. Создать \`ui/theme.slint\`: +\`\`\`slint +export global Theme { + in-out property dark-mode: true; + property bg-primary: dark-mode ? #0f131a : #f8fafc; + property bg-card: dark-mode ? #1a202c : #ffffff; + property bg-surface: dark-mode ? #111827 : #f1f5f9; + property border: dark-mode ? #334155 : #e2e8f0; + property text-primary: dark-mode ? #f1f5f9 : #0f172a; + property text-muted: dark-mode ? #64748b : #94a3b8; + property success: #22c55e; + property warning: #f59e0b; + property danger: #ef4444; +} +\`\`\` + +2. В \`LimitCard\` добавить: +\`\`\`slint +private property accent: + root.percent >= 90 ? Theme.danger : + root.percent >= 75 ? Theme.warning : Theme.success; +\`\`\` + +3. Большой процент: \`font-size: 44px; font-weight: 800; color: root.accent\` + +4. В панели настроек добавить CheckBox «Светлая тема» → переключает \`Theme.dark-mode\` + +5. Переименовать «пик» → «макс» везде в UI + +## Критерии приёмки + +- \`cargo build --features gui --locked\` без ошибок +- GUI запускается в demo-режиме, переключение темы работает +- Большой % виден сразу при открытии окна + +## Что нельзя трогать + +- \`src/\` Rust-код — только \`ui/\` файлы +- Логика polling и API" + +# Issue 3: Дневной лимит +gh issue create \ + --repo "$REPO" \ + --title "feat(core): add daily credit limit tracking" \ + --label "feat,agent,tui,gui" \ + --body "## Что сделать + +Добавить отслеживание дневного лимита расхода кредитов. + +## Логика + +Расчётный рекомендуемый лимит/день = \`остаток_7d / дней_до_сброса_7d\` + +Если пользователь задал свой лимит через \`--daily-limit N\` — использовать его. + +Хранить в \`~/.config/vimit/daily.toml\`: +\`\`\`toml +limit = 56.0 # кредиты на день (0 = не задан, считать авто) +date = \"2026-06-28\" # при смене даты сбрасывать +spent_today = 12.3 +\`\`\` + +## Что показывать + +В TUI (compact и full preset) — строка под окном 7d: +\`\`\` +День: 12.3 / 56.0 (22%) [████░░░░░░] +\`\`\` + +В JSON-выводе — поле \`daily\` рядом с \`windows\`. + +## CLI + +\`\`\` +vimit --daily-limit 56 # задать лимит +vimit --daily-limit 0 # сбросить (использовать авто) +\`\`\` + +## Критерии приёмки + +- \`cargo test --locked\` — все тесты зелёные +- \`vimit --demo --daily-limit 50\` показывает строку дневного лимита +- \`vimit --demo --json\` содержит поле \`daily\` +- При смене даты spent_today сбрасывается в 0 + +## Что нельзя трогать + +- \`ui/app.slint\` (GUI версия — отдельный Issue) +- \`SUBMISSION.ru.md\`" + +# Issue 4: Скриншоты в README +gh issue create \ + --repo "$REPO" \ + --title "docs(readme): add demo screenshots to README" \ + --label "docs,agent,priority:high" \ + --body "## Что сделать + +Добавить скриншоты TUI-монитора и GUI в README.md и README.ru.md. + +## Шаги + +1. Запустить \`.\target\release\vimit.exe --demo --monitor\`, сделать скриншот терминала → сохранить в \`assets/demo-tui.png\` + +2. Запустить \`.\target\release\vimit-gui.exe --demo\` (или \`--features gui\`), сделать скриншот → сохранить в \`assets/demo-gui.png\` + +3. В README.md после раздела \`## Features\` добавить: +\`\`\`markdown +## Screenshots + +![TUI Monitor](assets/demo-tui.png) +![GUI](assets/demo-gui.png) +\`\`\` + +4. То же самое в README.ru.md + +## Критерии приёмки + +- Оба файла \`assets/demo-tui.png\` и \`assets/demo-gui.png\` существуют в репо +- В обоих README изображения отображаются на странице GitHub +- Размер каждого PNG < 500KB + +## Что нельзя трогать + +- \`src/\`, \`ui/\`, \`Cargo.toml\`" + +# Issue 5: SUBMISSION update +gh issue create \ + --repo "$REPO" \ + --title "docs: update SUBMISSION.ru.md for v0.6.2 final" \ + --label "docs,agent,priority:high" \ + --body "## Что сделать + +Обновить \`SUBMISSION.ru.md\` для финальной подачи на конкурс (дедлайн 1 июля). + +## Конкретные изменения + +1. В разделе «Что уже работает» добавить: + - Система self-update (\`vimit update\` / \`vimit update --check\`) + - System tray tooltip с процентами окон + - Панель настроек с авто-проверкой обновлений + - Slint GUI с поддержкой dark/light темы (если Issue #N уже закрыт) + +2. Из раздела «Что ещё не готово» убрать пункты которые уже реализованы. + +3. Обновить версию в заголовке на v0.6.2. + +4. Формат подачи — заполнить все поля из объявления конкурса: +\`\`\` +Название: vimit +Что делает: ... +Для кого полезно: ... +GitHub: https://github.com/xodapi/vimit +Как запустить: ... +Какие ОС поддерживаются: Windows, Linux, macOS, Android/Termux +Что уже работает: ... +Что ещё не готово: ... +\`\`\` + +## Критерии приёмки + +- Версия в заголовке = v0.6.2 +- Все поля конкурсной формы заполнены +- Ни один реализованный пункт не стоит в «не готово» + +## Что нельзя трогать + +- \`src/\`, \`ui/\`, \`Cargo.toml\`, CI-файлы" + +echo "" +echo "=== Готово! ===" +echo "Открытые Issues:" +gh issue list --repo "$REPO" --state open --label agent diff --git a/docs/vimit_floating_overlay.html b/docs/vimit_floating_overlay.html new file mode 100644 index 0000000..ce04f18 --- /dev/null +++ b/docs/vimit_floating_overlay.html @@ -0,0 +1,255 @@ + + + +
+ +
+
+
+
+
5h: 78% · 1.8 кред/мин
+
нажать чтобы раскрыть
+
+
+
+ +
+
+ vimit · VibeMode +
+ + + +
+
+ +
+
+ 1.8 + кред/мин +
+ +12% к норме +
+ +
+ +
+ +
расход за 15 мин (кред/мин)
+
+ График расхода +
+ +
+ сброс 5h окна + через 3ч 59м +
+ +
+ + + +
+
+ +
← прототип плавающего оверлея для Issue агенту
+
+ + + diff --git a/issue_body.md b/issue_body.md new file mode 100644 index 0000000..9154451 --- /dev/null +++ b/issue_body.md @@ -0,0 +1,30 @@ +## Что сделать + +`cache_key()` в `src/cli/cache.rs` использует `api_key` в открытом виде как часть ключа записи в `redb`: + +```rust +fn cache_key(api_key: &str, api_base: &str) -> String { + format!("{}|{}", api_key, api_base.trim_end_matches('/')) +} +``` + +Это означает, что `VIBEMODE_API_KEY` хранится в открытом виде внутри `cache.redb` (лежит в `APPDATA` / `~/.config`). Любой пользователь системы с доступом к этому файлу может извлечь ключ. + +## Конкретные изменения + +1. Заменить `cache_key` на хеш от `api_key` + `api_base`: + - Использовать `std::collections::hash_map::DefaultHasher` для получения stable хеша + - Формат ключа: `|api_base` +2. Убедиться, что кэш по-прежнему корректно работает (читается и записывается) + +## Критерии приёмки + +- `cargo test --locked` проходит +- `cargo clippy --all-targets -- -D warnings` чистый +- Кэш продолжает работать корректно после изменения формата ключа + +## Что нельзя трогать + +- `Cargo.toml` (новые зависимости не нужны) +- Формат JSON-вывода +- Любые другие модули кроме `src/cli/cache.rs` diff --git a/overlay_issue.md b/overlay_issue.md new file mode 100644 index 0000000..49482db --- /dev/null +++ b/overlay_issue.md @@ -0,0 +1,13 @@ +Новая команда: `vimit overlay` (или `vimit --overlay`) +Открывает отдельное маленькое окно (300x260px) поверх всех окон (window_level: AlwaysOnTop в Slint/winit). + +Компоненты: +1. Строка расхода: "1.8 кред/мин" (большой шрифт) + дельта-бадж +2. 4 строки окон: `[5h] [████░░] 78% | через 3ч 59м` +3. Sparkline последних 15 точек polling (canvas или встроенный рендер) +4. Переключатель единиц: кред/мин / токены/мин +5. Кнопка свернуть → compact строка в системный трей или мини-бар +6. Таймер сброса ближайшего окна с обратным отсчётом + +Данные берутся из того же polling loop что и основной GUI. +zone:gui — только `ui/` и `src/bin/vimit-gui.rs` diff --git a/setup-issues.sh b/setup-issues.sh new file mode 100644 index 0000000..73054b8 --- /dev/null +++ b/setup-issues.sh @@ -0,0 +1,246 @@ +#!/bin/bash +# setup-issues.sh +# Создаёт стартовый набор Issues для vimit с правильными labels +# Запускать один раз: bash setup-issues.sh + +set -e + +REPO="xodapi/vimit" + +echo "=== Создание labels ===" + +labels=( + "agent|0075ca|Готово к выполнению агентом" + "blocked|e11d48|Заблокировано" + "gui|7c3aed|Изменения в Slint GUI" + "tui|0891b2|Изменения в ratatui TUI" + "priority:high|dc2626|Высокий приоритет" + "priority:low|6b7280|Низкий приоритет" +) + +for label_def in "${labels[@]}"; do + IFS='|' read -r name color desc <<< "$label_def" + gh label create "$name" --color "$color" --description "$desc" --repo "$REPO" 2>/dev/null || \ + gh label edit "$name" --color "$color" --description "$desc" --repo "$REPO" 2>/dev/null || true +done + +echo "=== Labels созданы ===" + +echo "=== Создание Issues ===" + +# Issue 1: CI fix (самый срочный) +gh issue create \ + --repo "$REPO" \ + --title "fix(ci): install gtk deps for linux, split gui feature matrix" \ + --label "ci,agent,priority:high" \ + --body "## Что сделать + +В \`.github/workflows/ci.yml\` исправить провал Linux-сборки с \`--features gui\`. + +## Конкретные изменения + +1. В job \`check\` для \`ubuntu-latest\` добавить шаг перед \`cargo check\`: +\`\`\`yaml +- name: Install Linux system deps + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get update && sudo apt-get install -y libglib2.0-dev libgtk-3-dev +\`\`\` + +2. Либо (проще): для \`ubuntu-latest\` убрать \`--features gui\` из clippy и test — GUI-фичу проверять только на Windows/macOS. + +3. В README.md и README.ru.md заменить бадж \`rust-2021\` на \`rust-2024\`. + +## Критерии приёмки + +- CI #48+ проходит зелёным на всех трёх ОС +- \`cargo test --locked\` локально чистый +- Бадж в README обновлён + +## Что нельзя трогать + +- \`src/\` — никаких изменений кода +- \`SUBMISSION.ru.md\`" + +# Issue 2: Theme system в Slint +gh issue create \ + --repo "$REPO" \ + --title "feat(gui): add theme.slint with dark/light mode support" \ + --label "feat,gui,agent" \ + --body "## Что сделать + +Создать \`ui/theme.slint\` с глобальной цветовой системой и подключить в \`ui/app.slint\`. + +## Конкретные изменения + +1. Создать \`ui/theme.slint\`: +\`\`\`slint +export global Theme { + in-out property dark-mode: true; + property bg-primary: dark-mode ? #0f131a : #f8fafc; + property bg-card: dark-mode ? #1a202c : #ffffff; + property bg-surface: dark-mode ? #111827 : #f1f5f9; + property border: dark-mode ? #334155 : #e2e8f0; + property text-primary: dark-mode ? #f1f5f9 : #0f172a; + property text-muted: dark-mode ? #64748b : #94a3b8; + property success: #22c55e; + property warning: #f59e0b; + property danger: #ef4444; +} +\`\`\` + +2. В \`LimitCard\` добавить: +\`\`\`slint +private property accent: + root.percent >= 90 ? Theme.danger : + root.percent >= 75 ? Theme.warning : Theme.success; +\`\`\` + +3. Большой процент: \`font-size: 44px; font-weight: 800; color: root.accent\` + +4. В панели настроек добавить CheckBox «Светлая тема» → переключает \`Theme.dark-mode\` + +5. Переименовать «пик» → «макс» везде в UI + +## Критерии приёмки + +- \`cargo build --features gui --locked\` без ошибок +- GUI запускается в demo-режиме, переключение темы работает +- Большой % виден сразу при открытии окна + +## Что нельзя трогать + +- \`src/\` Rust-код — только \`ui/\` файлы +- Логика polling и API" + +# Issue 3: Дневной лимит +gh issue create \ + --repo "$REPO" \ + --title "feat(core): add daily credit limit tracking" \ + --label "feat,agent,tui,gui" \ + --body "## Что сделать + +Добавить отслеживание дневного лимита расхода кредитов. + +## Логика + +Расчётный рекомендуемый лимит/день = \`остаток_7d / дней_до_сброса_7d\` + +Если пользователь задал свой лимит через \`--daily-limit N\` — использовать его. + +Хранить в \`~/.config/vimit/daily.toml\`: +\`\`\`toml +limit = 56.0 # кредиты на день (0 = не задан, считать авто) +date = \"2026-06-28\" # при смене даты сбрасывать +spent_today = 12.3 +\`\`\` + +## Что показывать + +В TUI (compact и full preset) — строка под окном 7d: +\`\`\` +День: 12.3 / 56.0 (22%) [████░░░░░░] +\`\`\` + +В JSON-выводе — поле \`daily\` рядом с \`windows\`. + +## CLI + +\`\`\` +vimit --daily-limit 56 # задать лимит +vimit --daily-limit 0 # сбросить (использовать авто) +\`\`\` + +## Критерии приёмки + +- \`cargo test --locked\` — все тесты зелёные +- \`vimit --demo --daily-limit 50\` показывает строку дневного лимита +- \`vimit --demo --json\` содержит поле \`daily\` +- При смене даты spent_today сбрасывается в 0 + +## Что нельзя трогать + +- \`ui/app.slint\` (GUI версия — отдельный Issue) +- \`SUBMISSION.ru.md\`" + +# Issue 4: Скриншоты в README +gh issue create \ + --repo "$REPO" \ + --title "docs(readme): add demo screenshots to README" \ + --label "docs,agent,priority:high" \ + --body "## Что сделать + +Добавить скриншоты TUI-монитора и GUI в README.md и README.ru.md. + +## Шаги + +1. Запустить \`.\target\release\vimit.exe --demo --monitor\`, сделать скриншот терминала → сохранить в \`assets/demo-tui.png\` + +2. Запустить \`.\target\release\vimit-gui.exe --demo\` (или \`--features gui\`), сделать скриншот → сохранить в \`assets/demo-gui.png\` + +3. В README.md после раздела \`## Features\` добавить: +\`\`\`markdown +## Screenshots + +![TUI Monitor](assets/demo-tui.png) +![GUI](assets/demo-gui.png) +\`\`\` + +4. То же самое в README.ru.md + +## Критерии приёмки + +- Оба файла \`assets/demo-tui.png\` и \`assets/demo-gui.png\` существуют в репо +- В обоих README изображения отображаются на странице GitHub +- Размер каждого PNG < 500KB + +## Что нельзя трогать + +- \`src/\`, \`ui/\`, \`Cargo.toml\`" + +# Issue 5: SUBMISSION update +gh issue create \ + --repo "$REPO" \ + --title "docs: update SUBMISSION.ru.md for v0.6.2 final" \ + --label "docs,agent,priority:high" \ + --body "## Что сделать + +Обновить \`SUBMISSION.ru.md\` для финальной подачи на конкурс (дедлайн 1 июля). + +## Конкретные изменения + +1. В разделе «Что уже работает» добавить: + - Система self-update (\`vimit update\` / \`vimit update --check\`) + - System tray tooltip с процентами окон + - Панель настроек с авто-проверкой обновлений + - Slint GUI с поддержкой dark/light темы (если Issue #N уже закрыт) + +2. Из раздела «Что ещё не готово» убрать пункты которые уже реализованы. + +3. Обновить версию в заголовке на v0.6.2. + +4. Формат подачи — заполнить все поля из объявления конкурса: +\`\`\` +Название: vimit +Что делает: ... +Для кого полезно: ... +GitHub: https://github.com/xodapi/vimit +Как запустить: ... +Какие ОС поддерживаются: Windows, Linux, macOS, Android/Termux +Что уже работает: ... +Что ещё не готово: ... +\`\`\` + +## Критерии приёмки + +- Версия в заголовке = v0.6.2 +- Все поля конкурсной формы заполнены +- Ни один реализованный пункт не стоит в «не готово» + +## Что нельзя трогать + +- \`src/\`, \`ui/\`, \`Cargo.toml\`, CI-файлы" + +echo "" +echo "=== Готово! ===" +echo "Открытые Issues:" +gh issue list --repo "$REPO" --state open --label agent diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..bdf76ad --- /dev/null +++ b/src/api.rs @@ -0,0 +1,215 @@ +use serde_json::Value; +use std::fs; +use std::time::{Duration, Instant}; + +use crate::{DEFAULT_API_BASE, VPN_API_BASE, update_offline_state}; + +pub struct Router { + endpoints: Vec, + failures: Vec, + degraded_until: Vec>, + current: usize, + threshold: u32, + cooldown: Duration, +} + +impl Router { + pub fn new(initial_base: String, fallbacks: Vec) -> Self { + let mut endpoints = vec![initial_base]; + let first = endpoints[0].clone(); + for fb in fallbacks { + if fb != first { + endpoints.push(fb); + } + } + let count = endpoints.len(); + Self { + endpoints, + failures: vec![0; count], + degraded_until: vec![None; count], + current: 0, + threshold: 3, + cooldown: Duration::from_secs(60), + } + } + + pub fn default_fallbacks() -> Vec { + vec![DEFAULT_API_BASE.to_string(), VPN_API_BASE.to_string()] + } + + pub fn active_endpoint(&self) -> &str { + &self.endpoints[self.current] + } + + pub fn active_label(&self) -> &str { + let url = &self.endpoints[self.current]; + if url.contains("r-api") { + "r-api" + } else if url.contains("api.vibe") { + "api" + } else { + "custom" + } + } + + pub fn record_success(&mut self) { + self.failures[self.current] = 0; + self.degraded_until[self.current] = None; + } + + pub fn record_failure(&mut self) -> bool { + self.failures[self.current] += 1; + if self.failures[self.current] >= self.threshold { + self.degraded_until[self.current] = Some(Instant::now() + self.cooldown); + self.failover(); + true + } else { + false + } + } + + fn failover(&mut self) { + let len = self.endpoints.len(); + for offset in 1..len { + let idx = (self.current + offset) % len; + let is_degraded = + matches!(self.degraded_until[idx], Some(until) if Instant::now() < until); + if !is_degraded { + self.current = idx; + return; + } + } + } +} + +pub struct HttpClient { + client: reqwest::blocking::Client, +} + +impl HttpClient { + pub fn new(user_agent: &str) -> Result { + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .user_agent(user_agent) + .build() + .map_err(|error| format!("cannot initialize HTTP client: {error}"))?; + Ok(Self { client }) + } + + pub fn fetch_me(&self, api_key: &str, api_base: &str) -> Result { + if api_key.is_empty() { + return Err("VIBEMODE_API_KEY is required unless --demo or --mock is used".to_string()); + } + + let url = format!("{}/v1/me", api_base.trim_end_matches('/')); + let response = self + .client + .get(url) + .bearer_auth(api_key) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .map_err(|error| format!("cannot reach VibeMode API: {error}"))?; + + let status = response.status(); + if !status.is_success() { + let code = status.as_u16(); + let hint = match code { + 401 => "check your VIBEMODE_API_KEY".to_string(), + 403 => "your API key does not have access".to_string(), + 404 => "check VIBEMODE_API_BASE".to_string(), + 429 => "rate limited — try --watch with a longer interval".to_string(), + _ if code >= 500 => { + "server error — try again later or check status.vibemod.pro".to_string() + } + _ => String::new(), + }; + let hint = if hint.is_empty() { + String::new() + } else { + format!("\n hint: {hint}") + }; + return Err(format!("VibeMode /v1/me returned HTTP {code}{hint}")); + } + + let value: Value = response + .json() + .map_err(|error| format!("VibeMode /v1/me returned invalid JSON: {error}"))?; + if !value.is_object() { + return Err("VibeMode /v1/me returned a non-object JSON payload".to_string()); + } + Ok(value) + } + + pub fn fetch_me_with_retry( + &self, + api_key: &str, + router: &mut Router, + api_base: &str, + ) -> Result<(Value, String), String> { + let max_retries: u32 = 3; + let base_delay_ms: u64 = 1000; + + for attempt in 0..=max_retries { + let endpoint = if attempt == 0 { + api_base + } else { + router.active_endpoint() + }; + match self.fetch_me(api_key, endpoint) { + Ok(value) => { + router.record_success(); + update_offline_state(false); + return Ok((value, router.active_label().to_string())); + } + Err(error) => { + let is_retryable = is_retryable_error(&error); + if !is_retryable { + update_offline_state(true); + return Err(error); + } + let failed_over = router.record_failure(); + if failed_over && attempt < max_retries { + continue; + } + if attempt == max_retries { + update_offline_state(true); + return Err(format!( + "{error} (after {} retr{})", + attempt + 1, + if attempt == 0 { "y" } else { "ies" } + )); + } + let delay_ms = base_delay_ms * (1u64 << attempt); + let jitter = (rand::random::() % 500).min(delay_ms / 2); + std::thread::sleep(std::time::Duration::from_millis(delay_ms + jitter)); + } + } + } + + Err("max retries exceeded".to_string()) + } +} + +fn is_retryable_error(error: &str) -> bool { + error.contains("HTTP 429") + || error.contains("HTTP 5") + || error.contains("cannot reach") + || error.contains("timed out") + || error.contains("connection") +} + +pub fn fetch_me(api_key: &str, api_base: &str, user_agent: &str) -> Result { + let http = HttpClient::new(user_agent)?; + http.fetch_me(api_key, api_base) +} + +pub fn load_mock(path: &str) -> Result { + let raw = + fs::read_to_string(path).map_err(|error| format!("cannot read mock payload: {error}"))?; + let value: Value = serde_json::from_str(&raw) + .map_err(|error| format!("mock payload is invalid JSON: {error}"))?; + if !value.is_object() { + return Err("mock payload must be a JSON object".to_string()); + } + Ok(value) +} diff --git a/src/bin/test.rs b/src/bin/test.rs new file mode 100644 index 0000000..8600faa --- /dev/null +++ b/src/bin/test.rs @@ -0,0 +1,7 @@ +fn main() { + let payload = vimit::demo_payload(); + let windows = vimit::summarize_me(&payload, 75.0, 90.0); + for w in windows { + println!("{:?}", w); + } +} diff --git a/src/bin/vimit-gui.rs b/src/bin/vimit-gui.rs index ea3c333..a7da004 100644 --- a/src/bin/vimit-gui.rs +++ b/src/bin/vimit-gui.rs @@ -3,6 +3,7 @@ use slint::{ComponentHandle, SharedString, Timer, TimerMode, Weak}; use std::cell::RefCell; use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; @@ -60,56 +61,20 @@ struct GuiAccount { } fn load_gui_accounts() -> (Vec, Vec) { - use std::fs; - use std::path::PathBuf; - let home = if cfg!(windows) { - std::env::var("APPDATA") - .ok() - .map(PathBuf::from) - .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from)) - } else { - std::env::var("HOME").ok().map(PathBuf::from) - }; - let Some(home) = home else { - return (vec![], vec![]); - }; - let config_dir = if cfg!(windows) { - home.join("vimit") + if let Ok(config) = ng::cli::accounts::AccountsConfig::load() { + let names = config.list_names(); + let configs: Vec = names + .iter() + .filter_map(|n| config.resolve(n).ok()) + .map(|r| GuiAccount { + api_key_env: r.api_key_env.clone(), + api_base: r.api_base.clone(), + }) + .collect(); + (names, configs) } else { - home.join(".config").join("vimit") - }; - let path = config_dir.join("accounts.toml"); - if !path.is_file() { - return (vec![], vec![]); + (vec![], vec![]) } - let raw = match fs::read_to_string(&path) { - Ok(r) => r, - Err(_) => return (vec![], vec![]), - }; - #[derive(serde::Deserialize)] - struct RawAcct { - api_key_env: Option, - api_base: Option, - } - #[derive(serde::Deserialize)] - struct RawRoot { - accounts: HashMap, - } - let parsed: RawRoot = match toml::from_str(&raw) { - Ok(p) => p, - Err(_) => return (vec![], vec![]), - }; - let mut names: Vec = parsed.accounts.keys().cloned().collect(); - names.sort(); - let configs: Vec = names - .iter() - .filter_map(|n| parsed.accounts.get(n)) - .map(|r| GuiAccount { - api_key_env: r.api_key_env.clone(), - api_base: r.api_base.clone(), - }) - .collect(); - (names, configs) } fn main() { @@ -198,6 +163,9 @@ fn main() { ng::Router::default_fallbacks(), ))); + let refresh_gen = Arc::new(AtomicU64::new(0)); + let is_refreshing = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (account_names, account_configs) = load_gui_accounts(); let current_acct = Arc::new(Mutex::new(None::)); @@ -213,10 +181,16 @@ fn main() { let state = current_acct.clone(); let configs = account_configs.clone(); + let weak_app = app.as_weak(); app.on_account_changed(move |name| { if let Some(idx) = account_names.iter().position(|n| n == name.as_str()) { - let mut cur = state.lock().unwrap(); - *cur = Some(configs[idx].clone()); + { + let mut cur = state.lock().unwrap(); + *cur = Some(configs[idx].clone()); + } + if let Some(app) = weak_app.upgrade() { + app.invoke_refresh_requested(); + } } }); } @@ -225,6 +199,8 @@ fn main() { let http_clone = http.clone(); let acct = current_acct.clone(); let router_clone = router.clone(); + let gen1 = refresh_gen.clone(); + let is_ref1 = is_refreshing.clone(); app.on_refresh_requested(move || { start_refresh( weak.clone(), @@ -234,6 +210,8 @@ fn main() { router_clone.clone(), ng::DEFAULT_WARNING_THRESHOLD, ng::DEFAULT_DANGER_THRESHOLD, + gen1.clone(), + is_ref1.clone(), ); }); @@ -241,6 +219,8 @@ fn main() { let http_clone = http.clone(); let acct = current_acct.clone(); let router_clone = router.clone(); + let gen2 = refresh_gen.clone(); + let is_ref2 = is_refreshing.clone(); app.on_demo_requested(move || { start_refresh( weak.clone(), @@ -250,6 +230,8 @@ fn main() { router_clone.clone(), ng::DEFAULT_WARNING_THRESHOLD, ng::DEFAULT_DANGER_THRESHOLD, + gen2.clone(), + is_ref2.clone(), ); }); @@ -257,6 +239,8 @@ fn main() { let http_clone = http.clone(); let acct = current_acct.clone(); let router_clone = router.clone(); + let gen3 = refresh_gen.clone(); + let is_ref3 = is_refreshing.clone(); app.on_settings_changed(move |warning, danger| { start_refresh( weak.clone(), @@ -266,6 +250,8 @@ fn main() { router_clone.clone(), warning as f64, danger as f64, + gen3.clone(), + is_ref3.clone(), ); }); @@ -274,6 +260,8 @@ fn main() { let http_clone = http.clone(); let acct = current_acct.clone(); let router_clone = router.clone(); + let gen4 = refresh_gen.clone(); + let is_ref4 = is_refreshing.clone(); timer.start(TimerMode::Repeated, Duration::from_secs(10), move || { start_refresh( weak.clone(), @@ -283,6 +271,8 @@ fn main() { router_clone.clone(), ng::DEFAULT_WARNING_THRESHOLD, ng::DEFAULT_DANGER_THRESHOLD, + gen4.clone(), + is_ref4.clone(), ); }); @@ -294,6 +284,8 @@ fn main() { router.clone(), ng::DEFAULT_WARNING_THRESHOLD, ng::DEFAULT_DANGER_THRESHOLD, + refresh_gen.clone(), + is_refreshing.clone(), ); if let Ok(dotenv) = gui_load_dotenv() { @@ -387,6 +379,43 @@ fn main() { } }); + app.on_open_accounts_config(move || { + let home = if cfg!(windows) { + std::env::var("APPDATA") + .ok() + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var("USERPROFILE") + .ok() + .map(std::path::PathBuf::from) + }) + } else { + std::env::var("HOME").ok().map(std::path::PathBuf::from) + }; + if let Some(home) = home { + let config_dir = if cfg!(windows) { + home.join("vimit") + } else { + home.join(".config").join("vimit") + }; + let accounts_toml = config_dir.join("accounts.toml"); + if accounts_toml.exists() { + #[cfg(windows)] + let _ = std::process::Command::new("explorer") + .arg(&accounts_toml) + .spawn(); + #[cfg(target_os = "macos")] + let _ = std::process::Command::new("open") + .arg(&accounts_toml) + .spawn(); + #[cfg(target_os = "linux")] + let _ = std::process::Command::new("xdg-open") + .arg(&accounts_toml) + .spawn(); + } + } + }); + app.run().expect("Slint event loop failed"); } @@ -407,12 +436,24 @@ fn start_refresh( router: Arc>, warning: f64, danger: f64, + generation: Arc, + is_refreshing: Arc, ) { + if is_refreshing.swap(true, Ordering::SeqCst) { + return; + } + + let my_gen = generation.fetch_add(1, Ordering::Relaxed) + 1; thread::spawn(move || { let result = load_dashboard(demo, &http, &account, warning, danger, &router); + if generation.load(Ordering::Relaxed) != my_gen { + is_refreshing.store(false, Ordering::SeqCst); + return; + } let _ = app.upgrade_in_event_loop(move |app| { apply_dashboard(&app, result); }); + is_refreshing.store(false, Ordering::SeqCst); }); } fn apply_dashboard(app: &AppWindow, result: Result) { diff --git a/src/cli/cache.rs b/src/cli/cache.rs index 738aaa2..6a02b07 100644 --- a/src/cli/cache.rs +++ b/src/cli/cache.rs @@ -115,7 +115,12 @@ impl CacheStore { } fn cache_key(api_key: &str, api_base: &str) -> String { - format!("{}|{}", api_key, api_base.trim_end_matches('/')) + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + api_key.hash(&mut hasher); + let hash = hasher.finish(); + format!("{:016x}|{}", hash, api_base.trim_end_matches('/')) } fn default_cache_path() -> Option { diff --git a/src/cli/daily.rs b/src/cli/daily.rs index 955cd75..2daa3aa 100644 --- a/src/cli/daily.rs +++ b/src/cli/daily.rs @@ -64,8 +64,11 @@ impl DailyFile { } let raw = toml::to_string_pretty(self) .map_err(|e| format!("cannot serialize daily config: {e}"))?; - fs::write(&path, raw) - .map_err(|e| format!("cannot save daily config {}: {e}", path.display()))?; + let tmp_path = path.with_extension("toml.tmp"); + fs::write(&tmp_path, &raw) + .map_err(|e| format!("cannot write daily config {}: {e}", tmp_path.display()))?; + fs::rename(&tmp_path, &path) + .map_err(|e| format!("cannot rename daily config {}: {e}", path.display()))?; Ok(()) } @@ -85,6 +88,41 @@ impl DailyFile { } } + pub fn atomic_update(current_remaining_7d: f64) -> Result { + let Some(path) = daily_config_path() else { + return Ok(Self::default()); + }; + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + + use fs3::FileExt; + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(&path) + .map_err(|e| format!("cannot open daily config: {e}"))?; + + let _ = file.lock_exclusive(); + + let mut raw = String::new(); + use std::io::{Read, Write, Seek, SeekFrom}; + let _ = file.read_to_string(&mut raw); + + let mut df: Self = toml::from_str(&raw).unwrap_or_default(); + df.update(current_remaining_7d); + + let new_raw = toml::to_string_pretty(&df).unwrap_or_default(); + let _ = file.seek(SeekFrom::Start(0)); + let _ = file.set_len(0); + let _ = file.write_all(new_raw.as_bytes()); + let _ = file.unlock(); + + Ok(df) + } + pub fn get_state(&self, limit_7d: f64, override_limit: Option) -> crate::DailyState { let limit = override_limit.unwrap_or_else(|| { if self.daily.limit > 0.0 { diff --git a/src/cli/monitor.rs b/src/cli/monitor.rs index 0c3333e..a7f729d 100644 --- a/src/cli/monitor.rs +++ b/src/cli/monitor.rs @@ -162,8 +162,9 @@ pub fn collect_status( }; if let Some(w7d) = snapshot.windows.clone().iter().find(|w| w.key == "7d") { if let Some(c) = w7d.credits.as_ref() { - daily_file.update(c.remaining); - let _ = daily_file.save(); + if let Ok(updated_file) = crate::cli::daily::DailyFile::atomic_update(c.remaining) { + *daily_file = updated_file; + } let daily_state = daily_file.get_state(c.limit, args.daily_limit); snapshot.windows.insert( @@ -320,12 +321,7 @@ pub fn run_monitor( .saturating_duration_since(Instant::now()) .as_secs(); - let current_account_name = if has_accounts && total_accounts > 1 { - Some(account_names[cur_account].as_str()) - } else { - None - }; - + // Removed current_account_name calculation here terminal .draw(|frame| { draw_frame( @@ -340,7 +336,8 @@ pub fn run_monitor( args.preset, args.theme, &panels, - current_account_name, + account_names, + cur_account, &trend_days, ); }) @@ -399,7 +396,8 @@ fn draw_frame( preset: Preset, theme: Theme, panels: &PanelState, - current_account: Option<&str>, + account_names: &[String], + cur_account: usize, trend_days: &[TrendDay], ) { if panels.show_help { @@ -434,7 +432,15 @@ fn draw_frame( let mut idx = 0; if panels.show_header { - draw_header(frame, chunks[idx], snapshot, &pal, current_account, panels); + draw_header( + frame, + chunks[idx], + snapshot, + &pal, + account_names, + cur_account, + panels, + ); idx += 1; } if panels.show_quota { @@ -457,13 +463,20 @@ fn draw_frame( idx += 1; } if panels.show_footer { + let current_account_name = if account_names.len() > 1 { + Some(account_names[cur_account].as_str()) + } else if account_names.len() == 1 { + Some(account_names[0].as_str()) + } else { + None + }; draw_footer( frame, chunks[idx], interval_secs, next_refresh_secs, &pal, - current_account, + current_account_name, ); } } @@ -473,12 +486,30 @@ fn draw_header( area: Rect, snapshot: Option<&StatusSnapshot>, pal: &Palette, - current_account: Option<&str>, + account_names: &[String], + cur_account: usize, panels: &PanelState, ) { - let account_prefix = current_account - .map(|name| format!(" [{name}]")) - .unwrap_or_default(); + let account_prefix = if account_names.len() > 1 { + let mut list = String::new(); + for (i, name) in account_names.iter().enumerate() { + if i > 0 { + list.push_str(" | "); + } + if i == cur_account { + list.push('*'); + list.push_str(name); + list.push('*'); + } else { + list.push_str(name); + } + } + format!(" [ {list} ] (Tab to switch)") + } else if account_names.len() == 1 { + format!(" [{}]", account_names[0]) + } else { + String::new() + }; let vpn_label = if panels.vpn_mode { " VPN" } else { " Dir" }; let (title, style) = match snapshot { Some(s) => { @@ -601,10 +632,16 @@ fn draw_body( ); } } else { - let waiting = Paragraph::new(Span::styled( - "Collecting VibeMode status...", - pal.muted_style(), - )) + let waiting = Paragraph::new(vec![ + Line::from(Span::styled( + "Collecting VibeMode status...", + pal.muted_style(), + )), + Line::from(Span::styled( + "Press r to refresh, ? for help", + pal.muted_style(), + )), + ]) .block( Block::default() .title("Limits") @@ -839,35 +876,36 @@ fn draw_footer( let has_multi_account = current_account.is_some(); let height = area.height; let footer = if height <= 1 { - let mut text = format!("q quit | r refresh | {interval_secs}s/{next_refresh_secs}s"); + let mut text = "? help | 5 trends".to_string(); if has_multi_account { - text = format!("Tab acct | {text}"); + text = format!("{text} | Tab account"); } + text = format!("{text} | q quit | r refresh | {interval_secs}s/{next_refresh_secs}s"); if let Some(latest) = super::update::latest_checked_version() { - text = format!("{text} | ⚠️ Update v{latest}!"); + text = format!("{text} | ⚠ Update v{latest}!"); } Paragraph::new(Line::from(vec![Span::styled(text, pal.muted_style())])) } else { let mut spans = vec![ - Span::styled(" q ", pal.key_binding_style()), - Span::raw("quit "), - Span::styled(" r ", pal.key_binding_style()), - Span::raw(format!( - "refresh auto {interval_secs}s next {next_refresh_secs}s" - )), + Span::styled(" ? ", pal.key_binding_style()), + Span::raw("help "), + Span::styled(" 5 ", pal.key_binding_style()), + Span::raw("trends "), ]; if has_multi_account { - let mut with_tab = vec![ - Span::styled(" Tab ", pal.key_binding_style()), - Span::raw("account "), - ]; - with_tab.extend(spans); - spans = with_tab; + spans.push(Span::styled(" Tab ", pal.key_binding_style())); + spans.push(Span::raw("account ")); } + spans.push(Span::styled(" q ", pal.key_binding_style())); + spans.push(Span::raw("quit ")); + spans.push(Span::styled(" r ", pal.key_binding_style())); + spans.push(Span::raw(format!( + "refresh auto {interval_secs}s next {next_refresh_secs}s" + ))); if let Some(latest) = super::update::latest_checked_version() { spans.push(Span::raw(" ")); spans.push(Span::styled( - format!("⚠️ Update v{latest} available! Run `vimit update`"), + format!("⚠ Update v{latest}"), pal.bold_level_style("warning"), )); } @@ -1326,7 +1364,7 @@ pub fn render_monitor_lines( lines.push(fit_text( &format!( - "q quit | Esc quit | r refresh now | auto {}s | next {}s | .env next to binary supported", + "? help | 5 trends | Tab account | q quit | r refresh | auto {}s | next {}s", interval_secs, next_refresh_secs ), width, @@ -1641,7 +1679,8 @@ mod tests { preset, theme, &panels, - None, + &[], + 0, &[], ); }) diff --git a/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_compact_preset.snap b/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_compact_preset.snap index 4b649ca..e33f190 100644 --- a/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_compact_preset.snap +++ b/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_compact_preset.snap @@ -26,4 +26,4 @@ expression: output └──────────────────────────────────────────────────────────────────────────────┘ ┌ agents ──────────────────────────────────────────────────────────────────────┐ └──────────────────────────────────────────────────────────────────────────────┘ -q quit | r refresh | 5s/3s +? help | 5 trends | q quit | r refresh | 5s/3s diff --git a/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_dracula_theme.snap b/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_dracula_theme.snap index 21306a1..6c2fe5b 100644 --- a/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_dracula_theme.snap +++ b/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_dracula_theme.snap @@ -40,5 +40,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ q quit r refresh auto 5s next 3s │ +│ ? help 5 trends q quit r refresh auto 5s next 3s │ └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_full_preset.snap b/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_full_preset.snap index e19d22b..5932141 100644 --- a/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_full_preset.snap +++ b/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_full_preset.snap @@ -40,5 +40,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ q quit r refresh auto 5s next 3s │ +│ ? help 5 trends q quit r refresh auto 5s next 3s │ └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_high_contrast_theme.snap b/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_high_contrast_theme.snap index 21306a1..6c2fe5b 100644 --- a/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_high_contrast_theme.snap +++ b/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_high_contrast_theme.snap @@ -40,5 +40,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ q quit r refresh auto 5s next 3s │ +│ ? help 5 trends q quit r refresh auto 5s next 3s │ └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_mini_preset.snap b/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_mini_preset.snap index 82a0fd0..0377baf 100644 --- a/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_mini_preset.snap +++ b/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_mini_preset.snap @@ -16,4 +16,4 @@ expression: output └──────────────────────────────────────────────────────────┘ ┌ alerts ──────────────────────────────────────────────────┐ ┌ agents ──────────────────────────────────────────────────┐ -q quit | r refresh | 5s/3s +? help | 5 trends | q quit | r refresh | 5s/3s diff --git a/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_waiting.snap b/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_waiting.snap index 2559248..8c034c5 100644 --- a/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_waiting.snap +++ b/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_waiting.snap @@ -7,7 +7,7 @@ expression: output └──────────────────────────────────────────────────────────────────────────────────────────────────┘ ┌Limits────────────────────────────────────────────────────────────────────────────────────────────┐ │Collecting VibeMode status... │ -│ │ +│Press r to refresh, ? for help │ │ │ │ │ │ │ @@ -30,5 +30,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ q quit r refresh auto 5s next 3s │ +│ ? help 5 trends q quit r refresh auto 5s next 3s │ └──────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_with_error.snap b/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_with_error.snap index c75c52d..f8cdc26 100644 --- a/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_with_error.snap +++ b/src/cli/snapshots/vimit__cli__monitor__tests__tui_snapshot_with_error.snap @@ -30,5 +30,5 @@ expression: output │ │ ! connection timeout──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ q quit r refresh auto 5s next 3s │ +│ ? help 5 trends q quit r refresh auto 5s next 3s │ └──────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/src/lib.rs b/src/lib.rs index a815d92..cc68edc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,15 +1,20 @@ #![allow(clippy::collapsible_if)] +pub mod api; pub mod cli; +pub mod parse; -use chrono::{DateTime, SecondsFormat, TimeZone, Utc}; -use serde_json::{Map, Value, json}; +pub use api::*; +pub use parse::*; + +use chrono::{SecondsFormat, Utc}; +use serde_json::{Value, json}; use std::collections::HashMap; use std::env; use std::fs; use std::path::PathBuf; use std::sync::Mutex; -use std::time::{Duration, Instant}; +use std::time::Instant; pub static OFFLINE_SINCE: Mutex> = Mutex::new(None); @@ -64,84 +69,6 @@ pub const WINDOWS: [(&str, &str, &str, &str); 4] = [ ), ]; -pub struct Router { - endpoints: Vec, - failures: Vec, - degraded_until: Vec>, - current: usize, - threshold: u32, - cooldown: Duration, -} - -impl Router { - pub fn new(initial_base: String, fallbacks: Vec) -> Self { - let mut endpoints = vec![initial_base]; - let first = endpoints[0].clone(); - for fb in fallbacks { - if fb != first { - endpoints.push(fb); - } - } - let count = endpoints.len(); - Self { - endpoints, - failures: vec![0; count], - degraded_until: vec![None; count], - current: 0, - threshold: 3, - cooldown: Duration::from_secs(60), - } - } - - pub fn default_fallbacks() -> Vec { - vec![DEFAULT_API_BASE.to_string(), VPN_API_BASE.to_string()] - } - - pub fn active_endpoint(&self) -> &str { - &self.endpoints[self.current] - } - - pub fn active_label(&self) -> &str { - let url = &self.endpoints[self.current]; - if url.contains("r-api") { - "r-api" - } else if url.contains("api.vibe") { - "api" - } else { - "custom" - } - } - - pub fn record_success(&mut self) { - self.failures[self.current] = 0; - self.degraded_until[self.current] = None; - } - - pub fn record_failure(&mut self) -> bool { - self.failures[self.current] += 1; - if self.failures[self.current] >= self.threshold { - self.degraded_until[self.current] = Some(Instant::now() + self.cooldown); - self.failover(); - true - } else { - false - } - } - - fn failover(&mut self) { - let len = self.endpoints.len(); - for offset in 1..len { - let idx = (self.current + offset) % len; - let is_degraded = - matches!(self.degraded_until[idx], Some(until) if Instant::now() < until); - if !is_degraded { - self.current = idx; - return; - } - } - } -} - #[derive(Debug, Clone)] pub struct Metric { pub used: f64, @@ -213,391 +140,13 @@ pub struct AgentStatus { pub token_rate: String, } -// ── API ───────────────────────────────────────────────────────────────────── - -pub struct HttpClient { - client: reqwest::blocking::Client, -} - -impl HttpClient { - pub fn new(user_agent: &str) -> Result { - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .user_agent(user_agent) - .build() - .map_err(|error| format!("cannot initialize HTTP client: {error}"))?; - Ok(Self { client }) - } - - pub fn fetch_me(&self, api_key: &str, api_base: &str) -> Result { - if api_key.is_empty() { - return Err("VIBEMODE_API_KEY is required unless --demo or --mock is used".to_string()); - } - - let url = format!("{}/v1/me", api_base.trim_end_matches('/')); - let response = self - .client - .get(url) - .bearer_auth(api_key) - .header(reqwest::header::ACCEPT, "application/json") - .send() - .map_err(|error| format!("cannot reach VibeMode API: {error}"))?; - - let status = response.status(); - if !status.is_success() { - let code = status.as_u16(); - let hint = match code { - 401 => "check your VIBEMODE_API_KEY".to_string(), - 403 => "your API key does not have access".to_string(), - 404 => "check VIBEMODE_API_BASE".to_string(), - 429 => "rate limited — try --watch with a longer interval".to_string(), - _ if code >= 500 => { - "server error — try again later or check status.vibemod.pro".to_string() - } - _ => String::new(), - }; - let hint = if hint.is_empty() { - String::new() - } else { - format!("\n hint: {hint}") - }; - return Err(format!("VibeMode /v1/me returned HTTP {code}{hint}")); - } - - let value: Value = response - .json() - .map_err(|error| format!("VibeMode /v1/me returned invalid JSON: {error}"))?; - if !value.is_object() { - return Err("VibeMode /v1/me returned a non-object JSON payload".to_string()); - } - Ok(value) - } - - pub fn fetch_me_with_retry( - &self, - api_key: &str, - router: &mut Router, - api_base: &str, - ) -> Result<(Value, String), String> { - let max_retries: u32 = 3; - let base_delay_ms: u64 = 1000; - - for attempt in 0..=max_retries { - let endpoint = if attempt == 0 { - api_base - } else { - router.active_endpoint() - }; - match self.fetch_me(api_key, endpoint) { - Ok(value) => { - router.record_success(); - update_offline_state(false); - return Ok((value, router.active_label().to_string())); - } - Err(error) => { - let is_retryable = is_retryable_error(&error); - if !is_retryable { - update_offline_state(true); - return Err(error); - } - let failed_over = router.record_failure(); - if failed_over && attempt < max_retries { - continue; - } - if attempt == max_retries { - update_offline_state(true); - return Err(format!( - "{error} (after {} retr{})", - attempt + 1, - if attempt == 0 { "y" } else { "ies" } - )); - } - let delay_ms = base_delay_ms * (1u64 << attempt); - let jitter = (rand::random::() % 500).min(delay_ms / 2); - std::thread::sleep(std::time::Duration::from_millis(delay_ms + jitter)); - } - } - } - - Err("max retries exceeded".to_string()) - } -} - -fn is_retryable_error(error: &str) -> bool { - error.contains("HTTP 429") - || error.contains("HTTP 5") - || error.contains("cannot reach") - || error.contains("timed out") - || error.contains("connection") -} - -pub fn fetch_me(api_key: &str, api_base: &str, user_agent: &str) -> Result { - let http = HttpClient::new(user_agent)?; - http.fetch_me(api_key, api_base) -} - -pub fn load_mock(path: &str) -> Result { - let raw = - fs::read_to_string(path).map_err(|error| format!("cannot read mock payload: {error}"))?; - let value: Value = serde_json::from_str(&raw) - .map_err(|error| format!("mock payload is invalid JSON: {error}"))?; - if !value.is_object() { - return Err("mock payload must be a JSON object".to_string()); - } - Ok(value) -} - -// ── Payloads ──────────────────────────────────────────────────────────────── - -pub fn demo_payload() -> Value { - let now = Utc::now(); - let five_start = now - chrono::Duration::minutes(28); - let day_start = now - chrono::Duration::hours(5); - let week_start = now - chrono::Duration::days(1); - let month_start = now - chrono::Duration::days(9); - json!({ - "usage": { - "rows": [ - { - "model": "demo-model", - "creditLimit5Hours": 50, - "creditLimit24Hours": 180, - "creditLimit7Days": 600, - "creditLimit30Days": 2000, - "requestLimit5Hours": 1000, - "requestLimit24Hours": 4000, - "requestLimit7Days": 20000, - "requestLimit30Days": 80000, - "credits5Hours": 39, - "credits24Hours": 91, - "credits7Days": 214, - "credits30Days": 819, - "requests5Hours": 610, - "requests24Hours": 1510, - "requests7Days": 8300, - "requests30Days": 26000, - "window5HoursStartedAt": five_start.to_rfc3339_opts(SecondsFormat::Secs, true), - "window5HoursEndsAt": (now + chrono::Duration::hours(4)).to_rfc3339_opts(SecondsFormat::Secs, true), - "window24HoursStartedAt": day_start.to_rfc3339_opts(SecondsFormat::Secs, true), - "window24HoursEndsAt": (now + chrono::Duration::hours(19)).to_rfc3339_opts(SecondsFormat::Secs, true), - "window7DaysStartedAt": week_start.to_rfc3339_opts(SecondsFormat::Secs, true), - "window7DaysEndsAt": (now + chrono::Duration::days(6)).to_rfc3339_opts(SecondsFormat::Secs, true), - "window30DaysStartedAt": month_start.to_rfc3339_opts(SecondsFormat::Secs, true), - "window30DaysEndsAt": (now + chrono::Duration::days(21)).to_rfc3339_opts(SecondsFormat::Secs, true) - } - ] - } - }) -} - -// ── Parsing / Summarize ───────────────────────────────────────────────────── - -pub fn summarize_me( - payload: &Value, - warning_threshold: f64, - danger_threshold: f64, -) -> Vec { - summarize_me_with_thresholds( - payload, - warning_threshold, - danger_threshold, - &std::collections::HashMap::new(), - ) -} - -pub fn summarize_me_with_thresholds( - payload: &Value, - warning_threshold: f64, - danger_threshold: f64, - window_thresholds: &std::collections::HashMap, -) -> Vec { - let rows = extract_usage_rows(payload); - let now = Utc::now(); - let mut summaries = Vec::new(); - - for (key, suffix, _start_field, reset_field) in WINDOWS { - let credits = summarize_metric( - &rows, - &format!("credits{suffix}"), - &format!("creditLimit{suffix}"), - ); - let requests = summarize_metric( - &rows, - &format!("requests{suffix}"), - &format!("requestLimit{suffix}"), - ); - let reset_value = first_value(&rows, reset_field); - let (reset, reset_in_seconds) = parse_reset(reset_value, now); - let percent = peak_percent(credits.as_ref(), requests.as_ref()).unwrap_or(0.0); - - if credits.is_none() && requests.is_none() && reset_in_seconds.is_none() { - continue; - } - - let (w, d) = window_thresholds - .get(key) - .copied() - .unwrap_or((warning_threshold, danger_threshold)); - - let level = window_level(credits.as_ref(), requests.as_ref(), w, d); - - summaries.push(WindowState { - key, - level: level.to_string(), - reset, - reset_in_seconds, - credits, - requests, - percent, - }); - } - summaries -} - -fn extract_usage_rows(payload: &Value) -> Vec<&Map> { - // Try usage.rows at various nesting levels - if let Some(rows) = payload - .get("usage") - .and_then(Value::as_object) - .and_then(|u| u.get("rows")) - .and_then(Value::as_array) - { - let parsed = object_rows(rows); - if !parsed.is_empty() { - return parsed; - } - } - - if let Some(rows) = payload - .get("data") - .and_then(Value::as_object) - .and_then(|d| d.get("usage")) - .and_then(Value::as_object) - .and_then(|u| u.get("rows")) - .and_then(Value::as_array) - { - let parsed = object_rows(rows); - if !parsed.is_empty() { - return parsed; - } - } - - if let Some(rows) = payload.get("rows").and_then(Value::as_array) { - let parsed = object_rows(rows); - if !parsed.is_empty() { - return parsed; - } - } - - if let Some(rows) = payload - .get("data") - .and_then(Value::as_object) - .and_then(|d| d.get("rows")) - .and_then(Value::as_array) - { - let parsed = object_rows(rows); - if !parsed.is_empty() { - return parsed; - } - } - - if let Some(rows) = payload.get("usage").and_then(Value::as_array) { - let parsed = object_rows(rows); - if !parsed.is_empty() { - return parsed; - } - } - - // Last resort: scan all arrays for objects with credit/request fields - if let Some(object) = payload.as_object() { - for (_key, value) in object { - if let Some(rows) = value.as_array() { - let parsed = object_rows(rows); - if parsed.iter().any(|row| has_usage_fields(row)) { - return parsed; - } - } - } - if let Some(data) = object.get("data").and_then(Value::as_object) { - for (_key, value) in data { - if let Some(rows) = value.as_array() { - let parsed = object_rows(rows); - if parsed.iter().any(|row| has_usage_fields(row)) { - return parsed; - } - } - } - } - } - - Vec::new() -} - -fn has_usage_fields(row: &Map) -> bool { - for key in row.keys() { - let lower = key.to_lowercase(); - if (lower.contains("credit") || lower.contains("request") || lower.contains("limit")) - && row.get(key.as_str()).and_then(to_number).is_some() - { - return true; - } - } - false -} +// ── API (now in api.rs) ───────────────────────────────────────────────────── +// HttpClient, Router, fetch_me, load_mock moved to api.rs -fn object_rows(rows: &[Value]) -> Vec<&Map> { - rows.iter().filter_map(Value::as_object).collect() -} +// ── Parsing / Summarize (now in parse.rs) ──────────────────────────────────── +// demo_payload, summarize_me, etc. moved to parse.rs -fn summarize_metric( - rows: &[&Map], - used_field: &str, - limit_field: &str, -) -> Option { - let mut used_total = 0.0; - let mut limits = Vec::::new(); - let mut seen = false; - - for row in rows { - let used = row.get(used_field).and_then(to_number); - let limit = row.get(limit_field).and_then(to_number); - if used.is_none() && limit.is_none() { - continue; - } - seen = true; - used_total += used.unwrap_or(0.0); - if let Some(limit) = limit.filter(|&limit| { - limit > 0.0 - && !limits - .iter() - .any(|existing| (*existing - limit).abs() < f64::EPSILON) - }) { - limits.push(limit); - } - } - - let limit_total: f64 = limits.iter().sum(); - if !seen || limit_total <= 0.0 { - return None; - } - - let remaining = (limit_total - used_total).max(0.0); - let percent = ((used_total / limit_total) * 100.0).clamp(0.0, 999.0); - Some(Metric { - used: used_total, - limit: limit_total, - remaining, - percent, - }) -} - -fn first_value<'a>(rows: &[&'a Map], field: &str) -> Option<&'a Value> { - rows.iter().find_map(|row| match row.get(field) { - Some(Value::Null) | None => None, - Some(Value::String(text)) if text.is_empty() => None, - value => value, - }) -} +// ── Utility ───────────────────────────────────────────────────────────────── pub fn to_number(value: &Value) -> Option { match value { @@ -607,118 +156,6 @@ pub fn to_number(value: &Value) -> Option { } } -fn parse_datetime_value(value: &Value) -> Option> { - match value { - Value::Number(number) => number - .as_i64() - .and_then(|timestamp| Utc.timestamp_opt(timestamp, 0).single()), - Value::String(text) => { - let raw = text.trim(); - if raw.is_empty() { - None - } else if let Ok(timestamp) = raw.parse::() { - Utc.timestamp_opt(timestamp, 0).single() - } else { - DateTime::parse_from_rfc3339(raw) - .map(|datetime| datetime.with_timezone(&Utc)) - .ok() - } - } - _ => None, - } -} - -fn parse_reset(value: Option<&Value>, now: DateTime) -> (String, Option) { - let Some(value) = value else { - return ("сброс неизвестен".to_string(), None); - }; - - if let Some(datetime) = parse_datetime_value(value) { - let seconds = (datetime - now).num_seconds().max(0); - ( - format!("сброс {}", format_duration_secs(seconds)), - Some(seconds), - ) - } else { - let text = value - .as_str() - .map(str::to_string) - .unwrap_or_else(|| value.to_string()); - (format!("сброс в {text}"), None) - } -} - -pub fn window_level( - credits: Option<&Metric>, - requests: Option<&Metric>, - warning_threshold: f64, - danger_threshold: f64, -) -> &'static str { - let peak = peak_percent(credits, requests); - match peak { - Some(peak) if peak >= danger_threshold => "danger", - Some(peak) if peak >= warning_threshold => "warning", - Some(_) => "ok", - None => "unknown", - } -} - -pub fn peak_percent(credits: Option<&Metric>, requests: Option<&Metric>) -> Option { - [credits, requests] - .into_iter() - .flatten() - .map(|metric| metric.percent) - .fold(None, |peak: Option, percent| { - Some(peak.map_or(percent, |peak| peak.max(percent))) - }) -} - -pub fn peak_percent_all(windows: &[WindowState]) -> Option { - windows - .iter() - .map(|w| w.percent) - .fold(None, |peak: Option, value| { - Some(peak.map_or(value, |peak| peak.max(value))) - }) -} - -pub fn worst_level(windows: &[WindowState]) -> &str { - windows - .iter() - .map(|window| window.level.as_str()) - .max_by_key(|level| level_rank(level)) - .unwrap_or("unknown") -} - -fn level_rank(level: &str) -> u8 { - match level { - "danger" => 3, - "warning" => 2, - "ok" => 1, - _ => 0, - } -} - -pub fn rate_text( - credits: Option<&Metric>, - requests: Option<&Metric>, - start: Option<&Value>, - now: DateTime, -) -> String { - let Some(start) = start.and_then(parse_datetime_value) else { - return "темп окна: нет window*StartedAt".to_string(); - }; - let elapsed_minutes = ((now - start).num_seconds().max(60) as f64) / 60.0; - let credit_rate = credits - .map(|metric| format!("{}/мин", short_rate(metric.used / elapsed_minutes))) - .unwrap_or_else(|| "н/д".to_string()); - let request_rate = requests - .map(|metric| format!("{}/мин", short_rate(metric.used / elapsed_minutes))) - .unwrap_or_else(|| "н/д".to_string()); - - format!("темп окна: кред {credit_rate} | запр {request_rate}") -} - // ── Formatting ────────────────────────────────────────────────────────────── pub fn format_duration_secs(seconds: i64) -> String { @@ -1137,37 +574,6 @@ pub fn unquote_env_value(value: &str) -> &str { mod tests { use super::*; - #[test] - fn summarizes_credit_and_request_windows() { - let windows = summarize_me(&demo_payload(), 75.0, 90.0); - - assert_eq!( - windows.iter().map(|window| window.key).collect::>(), - ["5h", "24h", "7d", "30d"] - ); - assert_eq!(windows[0].level, "warning"); - assert_eq!(windows[0].credits.as_ref().unwrap().percent, 78.0); - } - - #[test] - fn repeated_limit_rows_do_not_double_count_cap() { - let payload = json!({ - "usage": { - "rows": [ - {"credits5Hours": 10, "creditLimit5Hours": 50}, - {"credits5Hours": 20, "creditLimit5Hours": 50} - ] - } - }); - - let windows = summarize_me(&payload, 75.0, 90.0); - - let credits = windows[0].credits.as_ref().unwrap(); - assert_eq!(credits.used, 30.0); - assert_eq!(credits.limit, 50.0); - assert_eq!(credits.percent, 60.0); - } - #[test] fn json_summary_has_no_account_identity() { let payload = json!({ @@ -1180,13 +586,6 @@ mod tests { assert!(!encoded.contains("usr_demo")); } - #[test] - fn custom_thresholds_change_window_level() { - let windows = summarize_me(&demo_payload(), 80.0, 95.0); - - assert_eq!(windows[0].level, "ok"); - } - #[test] fn parses_dotenv_without_leaking_comments() { let parsed = parse_dotenv( @@ -1206,53 +605,4 @@ mod tests { ); assert_eq!(parsed.get("ABTOP_BIN").unwrap(), "abtop"); } - - #[test] - fn peak_percent_returns_max() { - let windows = summarize_me(&demo_payload(), 75.0, 90.0); - assert_eq!( - peak_percent(windows[0].credits.as_ref(), windows[0].requests.as_ref()).unwrap(), - 78.0 - ); - } - - #[test] - fn extract_usage_rows_handles_nested_data_usage() { - let payload = json!({ - "data": { - "usage": { - "rows": [{"credits5Hours": 10, "creditLimit5Hours": 100}] - } - } - }); - let windows = summarize_me(&payload, 75.0, 90.0); - assert!(!windows.is_empty()); - assert_eq!(windows[0].credits.as_ref().unwrap().used, 10.0); - } - - #[test] - fn extract_usage_rows_handles_flat_rows() { - let payload = json!({ - "rows": [{"credits5Hours": 20, "creditLimit5Hours": 100}] - }); - let windows = summarize_me(&payload, 75.0, 90.0); - assert!(!windows.is_empty()); - } - - #[test] - fn extract_usage_rows_handles_string_numbers() { - let payload = json!({ - "usage": {"rows": [{"credits5Hours": "30", "creditLimit5Hours": "100"}]} - }); - let windows = summarize_me(&payload, 75.0, 90.0); - assert!(!windows.is_empty()); - assert_eq!(windows[0].credits.as_ref().unwrap().used, 30.0); - } - - #[test] - fn summarize_me_returns_empty_for_unknown_schema() { - let payload = json!({"something": "unrelated"}); - let windows = summarize_me(&payload, 75.0, 90.0); - assert!(windows.is_empty()); - } } diff --git a/src/parse.rs b/src/parse.rs new file mode 100644 index 0000000..fa90a19 --- /dev/null +++ b/src/parse.rs @@ -0,0 +1,455 @@ +use chrono::{DateTime, SecondsFormat, TimeZone, Utc}; +use serde_json::{Map, Value, json}; +use std::collections::HashMap; + +use crate::{Metric, WINDOWS, WindowState, format_duration_secs, short_rate, to_number}; + +pub fn demo_payload() -> Value { + let now = Utc::now(); + let five_start = now - chrono::Duration::minutes(28); + let day_start = now - chrono::Duration::hours(5); + let week_start = now - chrono::Duration::days(1); + let month_start = now - chrono::Duration::days(9); + json!({ + "usage": { + "rows": [ + { + "model": "demo-model", + "creditLimit5Hours": 50, + "creditLimit24Hours": 180, + "creditLimit7Days": 600, + "creditLimit30Days": 2000, + "requestLimit5Hours": 1000, + "requestLimit24Hours": 4000, + "requestLimit7Days": 20000, + "requestLimit30Days": 80000, + "credits5Hours": 39, + "credits24Hours": 91, + "credits7Days": 214, + "credits30Days": 819, + "requests5Hours": 610, + "requests24Hours": 1510, + "requests7Days": 8300, + "requests30Days": 26000, + "window5HoursStartedAt": five_start.to_rfc3339_opts(SecondsFormat::Secs, true), + "window5HoursEndsAt": (now + chrono::Duration::hours(4)).to_rfc3339_opts(SecondsFormat::Secs, true), + "window24HoursStartedAt": day_start.to_rfc3339_opts(SecondsFormat::Secs, true), + "window24HoursEndsAt": (now + chrono::Duration::hours(19)).to_rfc3339_opts(SecondsFormat::Secs, true), + "window7DaysStartedAt": week_start.to_rfc3339_opts(SecondsFormat::Secs, true), + "window7DaysEndsAt": (now + chrono::Duration::days(6)).to_rfc3339_opts(SecondsFormat::Secs, true), + "window30DaysStartedAt": month_start.to_rfc3339_opts(SecondsFormat::Secs, true), + "window30DaysEndsAt": (now + chrono::Duration::days(21)).to_rfc3339_opts(SecondsFormat::Secs, true) + } + ] + } + }) +} + +pub fn summarize_me( + payload: &Value, + warning_threshold: f64, + danger_threshold: f64, +) -> Vec { + summarize_me_with_thresholds( + payload, + warning_threshold, + danger_threshold, + &HashMap::new(), + ) +} + +pub fn summarize_me_with_thresholds( + payload: &Value, + warning_threshold: f64, + danger_threshold: f64, + window_thresholds: &HashMap, +) -> Vec { + let rows = extract_usage_rows(payload); + let now = Utc::now(); + let mut summaries = Vec::new(); + + for (key, suffix, _start_field, reset_field) in WINDOWS { + let credits = summarize_metric( + &rows, + &format!("credits{suffix}"), + &format!("creditLimit{suffix}"), + ); + let requests = summarize_metric( + &rows, + &format!("requests{suffix}"), + &format!("requestLimit{suffix}"), + ); + let reset_value = first_value(&rows, reset_field); + let (reset, reset_in_seconds) = parse_reset(reset_value, now); + let percent = peak_percent(credits.as_ref(), requests.as_ref()).unwrap_or(0.0); + + if credits.is_none() && requests.is_none() && reset_in_seconds.is_none() { + continue; + } + + let (w, d) = window_thresholds + .get(key) + .copied() + .unwrap_or((warning_threshold, danger_threshold)); + + let level = window_level(credits.as_ref(), requests.as_ref(), w, d); + + summaries.push(WindowState { + key, + level: level.to_string(), + reset, + reset_in_seconds, + credits, + requests, + percent, + }); + } + summaries +} + +fn extract_usage_rows(payload: &Value) -> Vec<&Map> { + if let Some(rows) = payload + .get("usage") + .and_then(Value::as_object) + .and_then(|u| u.get("rows")) + .and_then(Value::as_array) + { + let parsed = object_rows(rows); + if !parsed.is_empty() { + return parsed; + } + } + + if let Some(rows) = payload + .get("data") + .and_then(Value::as_object) + .and_then(|d| d.get("usage")) + .and_then(Value::as_object) + .and_then(|u| u.get("rows")) + .and_then(Value::as_array) + { + let parsed = object_rows(rows); + if !parsed.is_empty() { + return parsed; + } + } + + if let Some(rows) = payload.get("rows").and_then(Value::as_array) { + let parsed = object_rows(rows); + if !parsed.is_empty() { + return parsed; + } + } + + if let Some(rows) = payload + .get("data") + .and_then(Value::as_object) + .and_then(|d| d.get("rows")) + .and_then(Value::as_array) + { + let parsed = object_rows(rows); + if !parsed.is_empty() { + return parsed; + } + } + + if let Some(rows) = payload.get("usage").and_then(Value::as_array) { + let parsed = object_rows(rows); + if !parsed.is_empty() { + return parsed; + } + } + + if let Some(object) = payload.as_object() { + for (_key, value) in object { + if let Some(rows) = value.as_array() { + let parsed = object_rows(rows); + if parsed.iter().any(|row| has_usage_fields(row)) { + return parsed; + } + } + } + if let Some(data) = object.get("data").and_then(Value::as_object) { + for (_key, value) in data { + if let Some(rows) = value.as_array() { + let parsed = object_rows(rows); + if parsed.iter().any(|row| has_usage_fields(row)) { + return parsed; + } + } + } + } + } + + Vec::new() +} + +fn has_usage_fields(row: &Map) -> bool { + for key in row.keys() { + let lower = key.to_lowercase(); + if (lower.contains("credit") || lower.contains("request") || lower.contains("limit")) + && row.get(key.as_str()).and_then(to_number).is_some() + { + return true; + } + } + false +} + +fn object_rows(rows: &[Value]) -> Vec<&Map> { + rows.iter().filter_map(Value::as_object).collect() +} + +fn summarize_metric( + rows: &[&Map], + used_field: &str, + limit_field: &str, +) -> Option { + let mut used_total = 0.0; + let mut limits = Vec::::new(); + let mut seen = false; + + for row in rows { + let used = row.get(used_field).and_then(to_number); + let limit = row.get(limit_field).and_then(to_number); + if used.is_none() && limit.is_none() { + continue; + } + seen = true; + used_total += used.unwrap_or(0.0); + if let Some(limit) = limit.filter(|&l: &f64| { + l > 0.0 + && !limits + .iter() + .any(|existing| (*existing - l).abs() < f64::EPSILON) + }) { + limits.push(limit); + } + } + + let limit_total: f64 = limits.iter().sum(); + if !seen || limit_total <= 0.0 { + return None; + } + + let remaining = (limit_total - used_total).max(0.0); + let percent = ((used_total / limit_total) * 100.0).clamp(0.0, 999.0); + Some(Metric { + used: used_total, + limit: limit_total, + remaining, + percent, + }) +} + +fn first_value<'a>(rows: &[&'a Map], field: &str) -> Option<&'a Value> { + rows.iter().find_map(|row| match row.get(field) { + Some(Value::Null) | None => None, + Some(Value::String(text)) if text.is_empty() => None, + value => value, + }) +} + +fn parse_datetime_value(value: &Value) -> Option> { + match value { + Value::Number(number) => number + .as_i64() + .and_then(|timestamp| Utc.timestamp_opt(timestamp, 0).single()), + Value::String(text) => { + let raw = text.trim(); + if raw.is_empty() { + None + } else if let Ok(timestamp) = raw.parse::() { + Utc.timestamp_opt(timestamp, 0).single() + } else { + DateTime::parse_from_rfc3339(raw) + .map(|datetime| datetime.with_timezone(&Utc)) + .ok() + } + } + _ => None, + } +} + +fn parse_reset(value: Option<&Value>, now: DateTime) -> (String, Option) { + let Some(value) = value else { + return ("сброс неизвестен".to_string(), None); + }; + + if let Some(datetime) = parse_datetime_value(value) { + let seconds = (datetime - now).num_seconds().max(0); + ( + format!("сброс {}", format_duration_secs(seconds)), + Some(seconds), + ) + } else { + let text = value + .as_str() + .map(str::to_string) + .unwrap_or_else(|| value.to_string()); + (format!("сброс в {text}"), None) + } +} + +pub fn window_level( + credits: Option<&Metric>, + requests: Option<&Metric>, + warning_threshold: f64, + danger_threshold: f64, +) -> &'static str { + let peak = peak_percent(credits, requests); + match peak { + Some(peak) if peak >= danger_threshold => "danger", + Some(peak) if peak >= warning_threshold => "warning", + Some(_) => "ok", + None => "unknown", + } +} + +pub fn peak_percent(credits: Option<&Metric>, requests: Option<&Metric>) -> Option { + [credits, requests] + .into_iter() + .flatten() + .map(|metric| metric.percent) + .fold(None, |peak: Option, percent| { + Some(peak.map_or(percent, |peak| peak.max(percent))) + }) +} + +pub fn peak_percent_all(windows: &[WindowState]) -> Option { + windows + .iter() + .map(|w| w.percent) + .fold(None, |peak: Option, value| { + Some(peak.map_or(value, |peak| peak.max(value))) + }) +} + +pub fn worst_level(windows: &[WindowState]) -> &str { + windows + .iter() + .map(|window| window.level.as_str()) + .max_by_key(|level| level_rank(level)) + .unwrap_or("unknown") +} + +fn level_rank(level: &str) -> u8 { + match level { + "danger" => 3, + "warning" => 2, + "ok" => 1, + _ => 0, + } +} + +pub fn rate_text( + credits: Option<&Metric>, + requests: Option<&Metric>, + start: Option<&Value>, + now: DateTime, +) -> String { + let Some(start) = start.and_then(parse_datetime_value) else { + return "темп окна: нет window*StartedAt".to_string(); + }; + let elapsed_minutes = ((now - start).num_seconds().max(60) as f64) / 60.0; + let credit_rate = credits + .map(|metric| format!("{}/мин", short_rate(metric.used / elapsed_minutes))) + .unwrap_or_else(|| "н/д".to_string()); + let request_rate = requests + .map(|metric| format!("{}/мин", short_rate(metric.used / elapsed_minutes))) + .unwrap_or_else(|| "н/д".to_string()); + + format!("темп окна: кред {credit_rate} | запр {request_rate}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn summarizes_credit_and_request_windows() { + let windows = summarize_me(&demo_payload(), 75.0, 90.0); + + assert_eq!( + windows.iter().map(|window| window.key).collect::>(), + ["5h", "24h", "7d", "30d"] + ); + assert_eq!(windows[0].level, "warning"); + assert_eq!(windows[0].credits.as_ref().unwrap().percent, 78.0); + } + + #[test] + fn repeated_limit_rows_do_not_double_count_cap() { + let payload = json!({ + "usage": { + "rows": [ + {"credits5Hours": 10, "creditLimit5Hours": 50}, + {"credits5Hours": 20, "creditLimit5Hours": 50} + ] + } + }); + + let windows = summarize_me(&payload, 75.0, 90.0); + + let credits = windows[0].credits.as_ref().unwrap(); + assert_eq!(credits.used, 30.0); + assert_eq!(credits.limit, 50.0); + assert_eq!(credits.percent, 60.0); + } + + #[test] + fn custom_thresholds_change_window_level() { + let windows = summarize_me(&demo_payload(), 80.0, 95.0); + + assert_eq!(windows[0].level, "ok"); + } + + #[test] + fn peak_percent_returns_max() { + let windows = summarize_me(&demo_payload(), 75.0, 90.0); + assert_eq!( + peak_percent(windows[0].credits.as_ref(), windows[0].requests.as_ref()).unwrap(), + 78.0 + ); + } + + #[test] + fn extract_usage_rows_handles_nested_data_usage() { + let payload = json!({ + "data": { + "usage": { + "rows": [{"credits5Hours": 10, "creditLimit5Hours": 100}] + } + } + }); + let windows = summarize_me(&payload, 75.0, 90.0); + assert!(!windows.is_empty()); + assert_eq!(windows[0].credits.as_ref().unwrap().used, 10.0); + } + + #[test] + fn extract_usage_rows_handles_flat_rows() { + let payload = json!({ + "rows": [{"credits5Hours": 20, "creditLimit5Hours": 100}] + }); + let windows = summarize_me(&payload, 75.0, 90.0); + assert!(!windows.is_empty()); + } + + #[test] + fn extract_usage_rows_handles_string_numbers() { + let payload = json!({ + "usage": {"rows": [{"credits5Hours": "30", "creditLimit5Hours": "100"}]} + }); + let windows = summarize_me(&payload, 75.0, 90.0); + assert!(!windows.is_empty()); + assert_eq!(windows[0].credits.as_ref().unwrap().used, 30.0); + } + + #[test] + fn summarize_me_returns_empty_for_unknown_schema() { + let payload = json!({"something": "unrelated"}); + let windows = summarize_me(&payload, 75.0, 90.0); + assert!(windows.is_empty()); + } +} diff --git a/ui/app.slint b/ui/app.slint index b374c65..926674a 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -197,6 +197,7 @@ export component AppWindow inherits Window { callback auto-update-changed(bool); callback close-overlay(); callback open-config-dir(); + callback open-accounts-config(); in-out property error-text: ""; in-out property needs-setup: false; @@ -547,6 +548,11 @@ export component AppWindow inherits Window { } } + Button { + text: "📂 Config"; + clicked => { root.open-accounts-config(); } + } + Rectangle { } } }