vkusvill 0.0.0-dev.0 → 0.0.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -10
- package/README.ru.md +41 -10
- package/package.json +16 -6
- package/src/api.js +102 -26
- package/src/app.js +75 -7
- package/src/auth.js +457 -0
- package/src/cli.js +121 -6
- package/src/main.js +26 -8
- package/src/paths.js +20 -0
- package/src/product.js +5 -3
- package/src/secrets.js +133 -0
- package/src/store.js +1 -2
- package/src/sys.js +13 -1
- package/src/ui/product-ui.js +13 -1
- package/src/ui/widgets.js +101 -0
- package/src/views/browser.js +1 -1
- package/src/views/cart.js +37 -15
- package/src/views/catalog.js +96 -46
- package/src/views/overlays.js +9 -4
- package/src/views/product.js +8 -5
- package/src/views/profile.js +317 -34
- package/src/views/recipes.js +74 -35
- package/src/views/shops.js +66 -39
package/README.md
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
# vkusvill
|
|
1
|
+
# vkusvill
|
|
2
2
|
|
|
3
3
|
**English** · [Русский](README.ru.md)
|
|
4
4
|
|
|
5
|
-
An
|
|
5
|
+
An experimental VkusVill client for the terminal. A full-screen app drawn in text graphics on top of the [official VkusVill MCP server](https://github.com/vkustech/vkusvill-mcp-server-manifest): the catalog, deals, recipes, shops and a cart you check out on your phone by scanning a QR code straight from the terminal.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx vkusvill
|
|
9
|
-
npx
|
|
10
|
-
npx vkusvill-cli сырники # open straight into a search
|
|
8
|
+
npx vkusvill # the full-screen app
|
|
9
|
+
npx vkusvill сырники # open straight into a search
|
|
11
10
|
```
|
|
12
11
|
|
|
13
12
|
Needs Node 18.17+ and a terminal of at least 64×18. It looks best with 24-bit colour: iTerm2, WezTerm, Ghostty, kitty, the VS Code terminal. Terminal.app is limited to 256 colours.
|
|
@@ -24,13 +23,16 @@ The interface speaks Russian, like the store.
|
|
|
24
23
|
- **Recipes.** VkusVill's search and filters: exclude allergens, cooking time, difficulty, method. A recipe page has the steps and an ingredient checklist; `+` puts every ticked ingredient in the cart.
|
|
25
24
|
- **Shops.** Region, city, metro and amenities. City and metro lists narrow to the chosen region. Search by address with `/`, an "open now" count, opening hours turned into "open until 23:00" or "closed", courier-only dark stores marked as such. The map is drawn in Braille dots over the outline of the MKAD ring road and the Garden Ring; `z` zooms to the selected shop, `L` loads every shop of the filter onto the map.
|
|
26
25
|
- **Cart.** Quantities change with the arrows; the total is in big digits, with savings from deals, the nutrition of the whole cart and a breakdown by category. Prices older than six hours are re-checked quietly. Checkout creates a VkusVill link and shows a QR code: point your phone at it and the cart opens in the VkusVill app. Payment and delivery stay with VkusVill.
|
|
27
|
-
- **Profile.** Favourites and history are kept locally. Orders, "favourite product" and personal discounts
|
|
28
|
-
- **Everywhere.** Mouse (click, double click, wheel), a command palette on `Ctrl+K`, barcode lookup (`b`), help (`?`). Hotkeys also work with the Russian keyboard layout. The last tab, sorts and filters are remembered between runs.
|
|
26
|
+
- **Profile.** Favourites and history are kept locally. Orders, "favourite product" and personal discounts need you signed in to VkusVill — see below.
|
|
27
|
+
- **Everywhere.** Fully usable from the keyboard: `↑` at the top of any page climbs into the header, where `← →` walk the tabs and the sign-in button. Mouse too (click, double click, wheel), a command palette on `Ctrl+K`, barcode lookup (`b`), help (`?`). Hotkeys also work with the Russian keyboard layout. The last tab, sorts and filters are remembered between runs.
|
|
29
28
|
|
|
30
29
|
## Keys
|
|
31
30
|
|
|
32
31
|
| Key | Action |
|
|
33
32
|
|---|---|
|
|
33
|
+
| `↑ ↓` | move between the rows of a screen: search box, filter chips, list, buttons |
|
|
34
|
+
| `← →`, `⏎` | walk and press the chips and buttons of a row |
|
|
35
|
+
| `↑` at the top of a page | up to the header: `← →` walk the tabs and the sign-in button, `↓` or `⏎` go back |
|
|
34
36
|
| `1`…`6`, `Tab` | tabs |
|
|
35
37
|
| `/` | search |
|
|
36
38
|
| `↑↓`, `j k` | move |
|
|
@@ -50,6 +52,8 @@ The interface speaks Russian, like the store.
|
|
|
50
52
|
```bash
|
|
51
53
|
vv search творог --sort price_asc # a table; --json prints VkusVill's answer
|
|
52
54
|
vv deals --quantity
|
|
55
|
+
vv deals --personal # "6 discounts for me", signed in
|
|
56
|
+
vv orders # orders of the last 30 days, signed in
|
|
53
57
|
vv product 27695
|
|
54
58
|
vv barcode 4607167640050
|
|
55
59
|
vv recipes тыква
|
|
@@ -58,6 +62,22 @@ vv cart add 27695 2
|
|
|
58
62
|
vv cart link # link and QR code right in the output
|
|
59
63
|
```
|
|
60
64
|
|
|
65
|
+
## Signing in
|
|
66
|
+
|
|
67
|
+
Orders, the favourite product, personal discounts and a catalog with your availability need a VkusVill sign-in. VkusVill gives API access through a personal OAuth client tied to your loyalty card; there is no shared client an app could ship with.
|
|
68
|
+
|
|
69
|
+
1. Register a personal agent at [vkusvill.ru/oauth/registration/](https://vkusvill.ru/oauth/registration/) (sign in with your phone first). Tick "my service has no public URI": VkusVill then assigns `http://localhost:3000/callback`, which is exactly where `vv login` listens.
|
|
70
|
+
2. VkusVill emails you the client credentials.
|
|
71
|
+
3. Run `vv login` (or open Profile → a personal section → `⏎` in the app), enter the `client_id` (and the secret, if one was issued) and confirm in the browser. The app catches the answer on port 3000 itself; if that port is taken by another program, paste the address the browser ended up on.
|
|
72
|
+
|
|
73
|
+
Rights are per client: the form lets you tick catalog search, purchase history, loyalty and discounts, personal recommendations. The app asks for all of them; whatever your client was not given, VkusVill refuses, and the app drops it, says so and remembers — the next sign-in asks only for what the client has. A token is sent only with requests its rights cover: without the catalog right search stays anonymous, without purchase history the orders section explains what to tick. If you tick more on the site later, run `vv login --scope all`.
|
|
74
|
+
|
|
75
|
+
After that the access token renews itself in the background. `vv auth` shows the state and the granted rights, `vv logout` revokes the tokens at VkusVill. `VV_TOKEN` still works for a ready-made token and takes precedence.
|
|
76
|
+
|
|
77
|
+
Signed in, the catalog and deals become personal: stock at your delivery address shows in the lists, the product card and the cart ("in stock: 9", "out of stock"). Personal answers are cached in memory for ten minutes and never mixed with anonymous ones on disk.
|
|
78
|
+
|
|
79
|
+
Under the hood: authorization code + PKCE (S256) with a random `state`; the authorization server is discovered from the MCP server (RFC 9728, RFC 8414) and has to be an https VkusVill host; the loopback listener binds to 127.0.0.1 only and answers once. Refresh tokens rotate on every use and VkusVill (Ory Hydra) revokes the whole chain if an old one is reused, so refreshes are serialised across processes with a lock file — the app and `vv` in another terminal never race. A dead refresh token is forgotten; public features keep working anonymously.
|
|
80
|
+
|
|
61
81
|
## How it works
|
|
62
82
|
|
|
63
83
|
- `src/term` — a dependency-free terminal engine: a cell buffer that sends only changed cells, synchronised output without flicker, truecolor with a 256-colour fallback, SGR mouse, bracketed paste, wide characters.
|
|
@@ -71,7 +91,19 @@ vv cart link # link and QR code right in the output
|
|
|
71
91
|
- **Nothing leaves your machine except requests to VkusVill.** No analytics, no third-party servers.
|
|
72
92
|
- **Text from the network is cleaned** before it reaches the screen or the command output: terminal control characters (ESC, C1 codes such as `\x9b`) and bidi overrides are removed, so a product name cannot drive your terminal or disguise itself.
|
|
73
93
|
- **Links open only to `https://` VkusVill and Yandex hosts**, without a shell (on Windows via `rundll32`, not `cmd`). Photos load only from VkusVill hosts, with size limits.
|
|
74
|
-
- **
|
|
94
|
+
- **Credentials live in the OS credential store where there is one**, so signing in stays a one-time thing on every platform, with no passwords or prompts afterwards:
|
|
95
|
+
|
|
96
|
+
| Platform | Client secret and tokens |
|
|
97
|
+
|---|---|
|
|
98
|
+
| macOS | login Keychain |
|
|
99
|
+
| Windows | a file encrypted with DPAPI for your Windows account (what the Credential Manager uses), via PowerShell |
|
|
100
|
+
| Linux desktop | Secret Service — GNOME Keyring, KWallet — via `secret-tool` (package `libsecret-tools`) |
|
|
101
|
+
| Linux over SSH, WSL, containers | `auth.json`, owner-only (0600) — the same as npm keeps its token in `~/.npmrc` |
|
|
102
|
+
| CI | `VV_TOKEN` from the environment, nothing on disk |
|
|
103
|
+
|
|
104
|
+
Secrets are handed to the store on stdin, never as command-line arguments that other users could see in `ps`. `~/.config/vv-cli/auth.json` keeps the client id, expiry and rights. If a store refuses, the secrets stay in that file and the next sign-in or renewal tries the store again; `vv auth` always says where they are and how to move them to a store. `VV_SECRETS=file|keychain|dpapi|secret-service` forces a choice. `vv logout --forget` erases the client, its secret and the tokens.
|
|
105
|
+
- **Credentials go to VkusVill only**: the access token to `mcp.vkusvill.ru` (even if `VV_ENDPOINT` points elsewhere), the rest to the discovered VkusVill authorization server. Requests that carry them refuse redirects, so a 307 cannot forward a secret elsewhere. Type the client secret at the `vv login` prompt rather than passing `--client-secret`, which stays in shell history.
|
|
106
|
+
- **Orders, personal discounts and personalised results are never written to the cache.**
|
|
75
107
|
- **State** lives in `~/.config/vv-cli/state.json`, readable by you only (0600). Writes are atomic; a damaged file is kept as `state.json.broken-<time>` instead of being overwritten; a second running copy or `vv cart add` updates the open app instead of clobbering it.
|
|
76
108
|
- **The cache** lives in `~/Library/Caches/vv-cli` or `~/.cache/vv-cli`, expires by itself and is capped at 300 MB. Crashes are logged to `vv-cli.log` next to it.
|
|
77
109
|
|
|
@@ -82,10 +114,9 @@ Ingredient flags ("sugar", "additives ×3" and so on) are heuristics over the fi
|
|
|
82
114
|
```bash
|
|
83
115
|
npm install
|
|
84
116
|
node bin/vv.js --no-splash
|
|
85
|
-
npm test # offline checks: terminal safety,
|
|
117
|
+
npm test # offline checks: terminal safety, sign-in against a mock, keyboard, credential store
|
|
86
118
|
node test/snap.js 120 36 # scripted run without a terminal → test/out/*.html (xterm.js) and gallery.html
|
|
87
119
|
```
|
|
88
120
|
|
|
89
121
|
Environment: `VV_TOKEN`, `VV_COLOR=256|truecolor`, `NO_COLOR`, `VV_NO_SPLASH`, `VV_CACHE_DIR`, `VV_CONFIG_DIR`, `VV_ENDPOINT`.
|
|
90
122
|
|
|
91
|
-
Not affiliated with VkusVill. Data and trademarks belong to their owners.
|
package/README.ru.md
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
# vkusvill
|
|
1
|
+
# vkusvill
|
|
2
2
|
|
|
3
3
|
[English](README.md) · **Русский**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Экспериментальный клиент ВкусВилла для терминала. Полноэкранное приложение на псевдографике поверх [официального MCP-сервера ВкусВилла](https://github.com/vkustech/vkusvill-mcp-server-manifest): каталог, акции, рецепты, магазины и корзина, которую можно оформить на телефоне по QR-коду прямо из терминала.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx vkusvill
|
|
9
|
-
npx
|
|
10
|
-
npx vkusvill-cli сырники # открыть сразу с поиском
|
|
8
|
+
npx vkusvill # полноэкранный интерфейс
|
|
9
|
+
npx vkusvill сырники # открыть сразу с поиском
|
|
11
10
|
```
|
|
12
11
|
|
|
13
12
|
Нужен Node 18.17+ и терминал от 64×18. Лучше всего выглядит с 24-битным цветом: iTerm2, WezTerm, Ghostty, kitty, терминал VS Code. В Terminal.app цветов 256.
|
|
@@ -22,13 +21,16 @@ npx vkusvill-cli сырники # открыть сразу с поиском
|
|
|
22
21
|
- **Рецепты.** Поиск и фильтры ВкусВилла: исключить аллергены, время, сложность, способ готовки. На странице рецепта шаги и чек-лист ингредиентов; `+` кладёт все отмеченные ингредиенты в корзину.
|
|
23
22
|
- **Магазины.** Регион, город, метро, особенности. Списки городов и метро сужаются по выбранному региону. Поиск по адресу клавишей `/`, сводка «открыто сейчас», часы работы превращаются в «открыт до 23:00» или «закрыт», склады «только для курьеров» помечены отдельно. Карта нарисована точками Брайля поверх контура МКАД и Садового кольца; `z` приближает к выбранному магазину, `L` загружает на карту все магазины фильтра.
|
|
24
23
|
- **Корзина.** Количество меняется стрелками; итог крупными цифрами, экономия по акциям, КБЖУ всей корзины и доли по категориям. Цены старше шести часов сверяются сами. Оформление создаёт ссылку ВкусВилла и показывает QR-код: наведите камеру телефона, и корзина откроется в приложении ВкусВилла. Оплата и доставка остаются на стороне ВкусВилла.
|
|
25
|
-
- **Профиль.** Избранное и история хранятся локально. Заказы, любимый продукт и «6 скидок»
|
|
26
|
-
- **Везде.** Мышь (клик, двойной клик, колесо), палитра команд по `Ctrl+K`, поиск по штрихкоду (`b`), справка (`?`). Горячие клавиши работают и в русской раскладке. Последняя вкладка, сортировки и фильтры запоминаются между запусками.
|
|
24
|
+
- **Профиль.** Избранное и история хранятся локально. Заказы, любимый продукт и «6 скидок» доступны после входа во ВкусВилл — см. ниже.
|
|
25
|
+
- **Везде.** Всё доступно с клавиатуры: `↑` у верхнего края любого раздела поднимает в шапку, где `← →` ходят по вкладкам и кнопке входа. Мышь тоже (клик, двойной клик, колесо), палитра команд по `Ctrl+K`, поиск по штрихкоду (`b`), справка (`?`). Горячие клавиши работают и в русской раскладке. Последняя вкладка, сортировки и фильтры запоминаются между запусками.
|
|
27
26
|
|
|
28
27
|
## Клавиши
|
|
29
28
|
|
|
30
29
|
| Клавиша | Действие |
|
|
31
30
|
|---|---|
|
|
31
|
+
| `↑ ↓` | по строкам экрана: поиск, чипы фильтров, список, кнопки |
|
|
32
|
+
| `← →`, `⏎` | по чипам и кнопкам строки, нажать выбранное |
|
|
33
|
+
| `↑` у верхнего края раздела | в шапку: `← →` по вкладкам и кнопке входа, `↓` или `⏎` обратно |
|
|
32
34
|
| `1`…`6`, `Tab` | вкладки |
|
|
33
35
|
| `/` | поиск |
|
|
34
36
|
| `↑↓`, `j k` | выбор |
|
|
@@ -48,6 +50,8 @@ npx vkusvill-cli сырники # открыть сразу с поиском
|
|
|
48
50
|
```bash
|
|
49
51
|
vv search творог --sort price_asc # таблица; --json — ответ ВкусВилла
|
|
50
52
|
vv deals --quantity
|
|
53
|
+
vv deals --personal # «6 скидок для меня», после входа
|
|
54
|
+
vv orders # заказы за 30 дней, после входа
|
|
51
55
|
vv product 27695
|
|
52
56
|
vv barcode 4607167640050
|
|
53
57
|
vv recipes тыква
|
|
@@ -56,6 +60,22 @@ vv cart add 27695 2
|
|
|
56
60
|
vv cart link # ссылка и QR-код прямо в выводе
|
|
57
61
|
```
|
|
58
62
|
|
|
63
|
+
## Вход
|
|
64
|
+
|
|
65
|
+
Заказы, любимый продукт, персональные скидки и каталог с вашим наличием требуют входа во ВкусВилл. Доступ к API ВкусВилл даёт через персонального OAuth-клиента, привязанного к вашей карте лояльности; общего клиента, которого можно было бы зашить в приложение, нет.
|
|
66
|
+
|
|
67
|
+
1. Зарегистрируйте персонального агента на [vkusvill.ru/oauth/registration/](https://vkusvill.ru/oauth/registration/) (сначала вход по телефону). Отметьте «У моего сервиса нет публичного URI»: ВкусВилл подставит `http://localhost:3000/callback` — именно там его и ждёт `vv login`.
|
|
68
|
+
2. ВкусВилл пришлёт данные клиента на почту.
|
|
69
|
+
3. Запустите `vv login` (или в приложении: Профиль → личный раздел → `⏎`), введите `client_id` (и секрет, если его выдали) и подтвердите вход в браузере. Приложение поймает ответ на порту 3000 само; если порт занят другой программой, вставьте адрес, на котором остановился браузер.
|
|
70
|
+
|
|
71
|
+
Права у каждого клиента свои: в форме отмечаются поиск по каталогу, история покупок, лояльность и скидки, персональные рекомендации. Приложение просит все; то, что клиенту не выдано, ВкусВилл отклоняет, а приложение убирает это право, сообщает об этом и запоминает — следующий вход просит только то, что у клиента есть. Токен уходит только с запросами, на которые у него есть права: без права на каталог поиск остаётся анонимным, без истории покупок раздел заказов подскажет, что отметить. Если позже отметите на сайте больше, запустите `vv login --scope all`.
|
|
72
|
+
|
|
73
|
+
Дальше access token продлевается сам в фоне. `vv auth` показывает состояние и выданные права, `vv logout` отзывает токены у ВкусВилла. `VV_TOKEN` по-прежнему работает для готового токена и важнее входа.
|
|
74
|
+
|
|
75
|
+
После входа каталог и акции становятся персональными: наличие по вашему адресу доставки видно в списках, карточке и корзине («в наличии 9 шт», «нет в наличии»). Персональные ответы хранятся в памяти десять минут и не смешиваются с анонимными на диске.
|
|
76
|
+
|
|
77
|
+
Как устроено: authorization code + PKCE (S256) со случайным `state`; сервер авторизации находится через MCP-сервер (RFC 9728, RFC 8414) и обязан быть https-адресом ВкусВилла; локальный приёмник слушает только 127.0.0.1 и отвечает один раз. Refresh token меняется при каждом использовании, а ВкусВилл (Ory Hydra) отзывает всю цепочку при повторном использовании старого, поэтому обновления между процессами идут строго по очереди через файл-блокировку — приложение и `vv` в соседнем терминале не мешают друг другу. Мёртвый refresh token забывается; публичные функции продолжают работать без входа.
|
|
78
|
+
|
|
59
79
|
## Как это устроено
|
|
60
80
|
|
|
61
81
|
- `src/term` — свой движок терминала без зависимостей: буфер ячеек, в терминал уходят только изменения, синхронный вывод без мерцания, truecolor с откатом на 256 цветов, мышь SGR, вставка из буфера обмена, широкие символы.
|
|
@@ -69,7 +89,19 @@ vv cart link # ссылка и QR-код прямо в в
|
|
|
69
89
|
- **С компьютера уходят только запросы к ВкусВиллу.** Никакой аналитики и сторонних серверов.
|
|
70
90
|
- **Текст из сети очищается**, прежде чем попасть на экран или в вывод команд: управляющие символы терминала (ESC, C1-коды вроде `\x9b`) и символы смены направления текста удаляются, так что название товара не может управлять терминалом или выдавать себя за другое.
|
|
71
91
|
- **Ссылки открываются только на `https://`-адреса ВкусВилла и Яндекса**, без оболочки (на Windows — через `rundll32`, а не `cmd`). Фото грузятся только с адресов ВкусВилла и с ограничением размера.
|
|
72
|
-
-
|
|
92
|
+
- **Учётные данные лежат в хранилище ОС, где оно есть**, поэтому вход везде одноразовый, без паролей и подтверждений потом:
|
|
93
|
+
|
|
94
|
+
| Платформа | Секрет клиента и токены |
|
|
95
|
+
|---|---|
|
|
96
|
+
| macOS | связка ключей |
|
|
97
|
+
| Windows | файл, зашифрованный DPAPI для вашей учётной записи Windows (на нём же «Диспетчер учётных данных»), через PowerShell |
|
|
98
|
+
| Linux с рабочим столом | Secret Service — GNOME Keyring, KWallet — через `secret-tool` (пакет `libsecret-tools`) |
|
|
99
|
+
| Linux по SSH, WSL, контейнеры | `auth.json`, доступный только вам (0600) — так же npm хранит токен в `~/.npmrc` |
|
|
100
|
+
| CI | `VV_TOKEN` из окружения, на диск ничего |
|
|
101
|
+
|
|
102
|
+
В хранилище секреты передаются через stdin, а не аргументами командной строки, которые другие пользователи видят в `ps`. В `~/.config/vv-cli/auth.json` остаются id клиента, срок действия и права. Если хранилище отказало, секреты остаются в этом файле, а следующий вход или продление снова пробуют хранилище; `vv auth` всегда говорит, где они и как перенести их в хранилище. `VV_SECRETS=file|keychain|dpapi|secret-service` задаёт выбор явно. `vv logout --forget` стирает клиента, его секрет и токены.
|
|
103
|
+
- **Учётные данные уходят только ВкусВиллу**: access token — на `mcp.vkusvill.ru` (даже если `VV_ENDPOINT` указывает в другое место), остальное — на найденный сервер авторизации ВкусВилла. Запросы с ними не следуют за перенаправлениями, так что ответ 307 не перешлёт секрет на чужой адрес. Секрет клиента вводите в ответ на вопрос `vv login`, а не флагом `--client-secret`: флаг остаётся в истории оболочки.
|
|
104
|
+
- **Заказы, персональные скидки и персонализированная выдача в кэш не пишутся.**
|
|
73
105
|
- **Состояние** хранится в `~/.config/vv-cli/state.json`, доступном только вам (0600). Запись атомарная; повреждённый файл сохраняется как `state.json.broken-<время>`, а не затирается; второй запущенный экземпляр или `vv cart add` обновляют открытое приложение, а не перезаписывают его корзину.
|
|
74
106
|
- **Кэш** лежит в `~/Library/Caches/vv-cli` или `~/.cache/vv-cli`, устаревает сам и не превышает 300 МБ. Падения записываются в `vv-cli.log` рядом с ним.
|
|
75
107
|
|
|
@@ -80,10 +112,9 @@ vv cart link # ссылка и QR-код прямо в в
|
|
|
80
112
|
```bash
|
|
81
113
|
npm install
|
|
82
114
|
node bin/vv.js --no-splash
|
|
83
|
-
npm test # проверки без сети: безопасность вывода,
|
|
115
|
+
npm test # проверки без сети: безопасность вывода, вход на моке, клавиатура, хранилище секретов
|
|
84
116
|
node test/snap.js 120 36 # сценарий без терминала → test/out/*.html (xterm.js) и gallery.html
|
|
85
117
|
```
|
|
86
118
|
|
|
87
119
|
Окружение: `VV_TOKEN`, `VV_COLOR=256|truecolor`, `NO_COLOR`, `VV_NO_SPLASH`, `VV_CACHE_DIR`, `VV_CONFIG_DIR`, `VV_ENDPOINT`.
|
|
88
120
|
|
|
89
|
-
Проект не связан с ВкусВиллом. Данные и товарные знаки принадлежат их владельцам.
|
package/package.json
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vkusvill",
|
|
3
|
-
"version": "0.0.0-dev.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.0-dev.1",
|
|
4
|
+
"description": "Experimental VkusVill client for the terminal: catalog, deals, recipes, shops and a cart with a QR code, drawn in text graphics on top of VkusVill's official MCP server",
|
|
5
|
+
"author": "Anton Golub <golub.anton@gmail.com>",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/vkustech/vkusvill-cli.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/vkustech/vkusvill-cli#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/vkustech/vkusvill-cli/issues"
|
|
13
|
+
},
|
|
5
14
|
"type": "module",
|
|
6
15
|
"bin": {
|
|
7
|
-
"vkusvill
|
|
16
|
+
"vkusvill": "bin/vv.js",
|
|
8
17
|
"vv": "bin/vv.js"
|
|
9
18
|
},
|
|
10
19
|
"files": [
|
|
@@ -19,7 +28,7 @@
|
|
|
19
28
|
},
|
|
20
29
|
"scripts": {
|
|
21
30
|
"start": "node bin/vv.js",
|
|
22
|
-
"test": "node test/check.mjs",
|
|
31
|
+
"test": "node test/check.mjs && node test/auth-check.mjs && node test/keys-check.mjs && node test/store-check.mjs",
|
|
23
32
|
"snap": "node test/snap.js"
|
|
24
33
|
},
|
|
25
34
|
"keywords": [
|
|
@@ -27,9 +36,10 @@
|
|
|
27
36
|
"вкусвилл",
|
|
28
37
|
"tui",
|
|
29
38
|
"cli",
|
|
30
|
-
"mcp",
|
|
31
39
|
"terminal",
|
|
32
|
-
"grocery"
|
|
40
|
+
"grocery",
|
|
41
|
+
"mcp",
|
|
42
|
+
"qr"
|
|
33
43
|
],
|
|
34
44
|
"license": "MIT",
|
|
35
45
|
"dependencies": {
|
package/src/api.js
CHANGED
|
@@ -1,30 +1,39 @@
|
|
|
1
1
|
// Client for the official VkusVill MCP server (https://mcp.vkusvill.ru/mcp): JSON-RPC tools/call,
|
|
2
|
-
// a disk cache, a priority queue under the ~60 requests/minute limit and
|
|
2
|
+
// a disk cache, a priority queue under the ~60 requests/minute limit and the user's OAuth token (auth.js).
|
|
3
3
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
-
import { mkdir, readFile, writeFile, stat, readdir, unlink, utimes } from "node:fs/promises";
|
|
6
|
-
import { homedir } from "node:os";
|
|
5
|
+
import { mkdir, readFile, writeFile, stat, readdir, unlink, utimes, chmod } from "node:fs/promises";
|
|
7
6
|
import path from "node:path";
|
|
8
7
|
import { clean, cleanDeep } from "./safe.js";
|
|
8
|
+
import { CACHE_DIR } from "./paths.js";
|
|
9
|
+
import * as auth from "./auth.js";
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
export const TOKEN = process.env.VV_TOKEN || null;
|
|
11
|
+
let ENDPOINT = process.env.VV_ENDPOINT ?? "https://mcp.vkusvill.ru/mcp";
|
|
12
12
|
// The token is only ever sent to the official server, even when VV_ENDPOINT points elsewhere
|
|
13
|
-
|
|
13
|
+
let OFFICIAL = (() => {
|
|
14
14
|
try { const u = new URL(ENDPOINT); return u.protocol === "https:" && u.hostname === "mcp.vkusvill.ru"; } catch { return false; }
|
|
15
15
|
})();
|
|
16
16
|
|
|
17
|
+
// For tests only: a local mock server that is trusted with the token
|
|
18
|
+
export function _setEndpointForTests(url) { ENDPOINT = url; OFFICIAL = true; }
|
|
19
|
+
|
|
20
|
+
// Tools that only work for a signed-in user
|
|
21
|
+
const PERSONAL = new Set(["vkusvill_orders_history", "vkusvill_product_lp", "vkusvill_discounts_personal"]);
|
|
22
|
+
// Scopes known to gate a tool. Only certain pairs: elsewhere the server's answer decides
|
|
23
|
+
const TOOL_SCOPE = { vkusvill_products_search: "catalog.search", vkusvill_orders_history: "purchase_history.read" };
|
|
24
|
+
|
|
25
|
+
const scopeError = (tool) => {
|
|
26
|
+
const need = TOOL_SCOPE[tool];
|
|
27
|
+
return new VVError(need
|
|
28
|
+
? `Клиенту не выдано «${auth.scopeLabel(need)}» — отметьте это действие в настройках клиента на vkusvill.ru, затем vv login --scope all`
|
|
29
|
+
: "Клиенту не хватает прав для этого — проверьте действия в настройках клиента на vkusvill.ru, затем vv login --scope all", "scope");
|
|
30
|
+
};
|
|
31
|
+
|
|
17
32
|
const MAX_RESPONSE = 20 * 1024 * 1024;
|
|
18
33
|
const MEM_LIMIT = 400;
|
|
19
34
|
const DAY = 24 * 3600e3;
|
|
20
35
|
|
|
21
|
-
|
|
22
|
-
if (process.env.XDG_CACHE_HOME) return process.env.XDG_CACHE_HOME;
|
|
23
|
-
if (process.platform === "darwin") return path.join(homedir(), "Library", "Caches");
|
|
24
|
-
if (process.platform === "win32" && process.env.LOCALAPPDATA) return process.env.LOCALAPPDATA;
|
|
25
|
-
return path.join(homedir(), ".cache");
|
|
26
|
-
}
|
|
27
|
-
export const CACHE_DIR = process.env.VV_CACHE_DIR ?? path.join(cacheBase(), "vv-cli");
|
|
36
|
+
export { CACHE_DIR };
|
|
28
37
|
|
|
29
38
|
const TTL = { default: 6 * 3600e3, vkusvill_shops: 7 * DAY, vkusvill_recipes: DAY, vkusvill_products_discount: 3600e3 };
|
|
30
39
|
// Personal data and one-off links never touch the disk
|
|
@@ -62,6 +71,8 @@ export async function diskPut(kind, key, value) {
|
|
|
62
71
|
const PRUNE = { api: 8 * DAY, img: 31 * DAY };
|
|
63
72
|
const CACHE_CAP = 300 * 1024 * 1024;
|
|
64
73
|
export async function pruneCache() {
|
|
74
|
+
// Older versions created the folder world-readable
|
|
75
|
+
try { await chmod(CACHE_DIR, 0o700); } catch {}
|
|
65
76
|
const marker = path.join(CACHE_DIR, ".pruned");
|
|
66
77
|
try { if (Date.now() - (await stat(marker)).mtimeMs < DAY) return; } catch {}
|
|
67
78
|
const files = [];
|
|
@@ -133,24 +144,69 @@ function parseJSON(text, what) {
|
|
|
133
144
|
try { return JSON.parse(text); } catch { throw new VVError(`ВкусВилл прислал некорректный ${what}`, "rpc"); }
|
|
134
145
|
}
|
|
135
146
|
|
|
136
|
-
async function post(body) {
|
|
147
|
+
async function post(body, token) {
|
|
137
148
|
const headers = { "Content-Type": "application/json", Accept: "application/json, text/event-stream" };
|
|
138
|
-
if (
|
|
149
|
+
if (token && OFFICIAL) headers.Authorization = `Bearer ${token}`;
|
|
139
150
|
try {
|
|
140
|
-
|
|
151
|
+
// No redirects: one would carry the bearer token elsewhere
|
|
152
|
+
return await fetch(ENDPOINT, { method: "POST", headers, body, signal: AbortSignal.timeout(20e3), redirect: "error" });
|
|
141
153
|
} catch (e) {
|
|
142
154
|
if (e?.name === "TimeoutError") throw new VVError("ВкусВилл не ответил за 20 секунд", "net");
|
|
143
155
|
throw new VVError("Нет связи с ВкусВиллом", "net");
|
|
144
156
|
}
|
|
145
157
|
}
|
|
146
158
|
|
|
159
|
+
const signInHint = (e) => (e?.code === "invalid_grant" ? "Вход истёк — vv login или Профиль → войти" : e?.message ?? "Нужен вход: vv login или Профиль → войти");
|
|
160
|
+
|
|
161
|
+
// Personal tools need a token; public ones use it when there is one (personalised results)
|
|
162
|
+
// and quietly fall back to anonymous access when signing in fails
|
|
163
|
+
async function tokenFor(tool) {
|
|
164
|
+
if (!OFFICIAL) {
|
|
165
|
+
if (PERSONAL.has(tool)) throw new VVError("Вход работает только с mcp.vkusvill.ru", "auth");
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
// A token without the tool's scope is not sent: personal tools say so, public ones go anonymous
|
|
170
|
+
if (TOOL_SCOPE[tool] && !auth.allows(TOOL_SCOPE[tool])) {
|
|
171
|
+
if (PERSONAL.has(tool)) throw scopeError(tool);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const token = await auth.accessToken();
|
|
175
|
+
if (!token && PERSONAL.has(tool)) throw new VVError("Нужен вход: vv login или Профиль → войти", "auth");
|
|
176
|
+
return token;
|
|
177
|
+
} catch (e) {
|
|
178
|
+
if (PERSONAL.has(tool)) throw e instanceof VVError ? e : new VVError(signInHint(e), "auth");
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
147
183
|
async function rawCall(tool, args) {
|
|
148
184
|
const body = JSON.stringify({ jsonrpc: "2.0", id: Date.now(), method: "tools/call", params: { name: tool, arguments: args } });
|
|
185
|
+
let token = await tokenFor(tool);
|
|
186
|
+
let renewed = false;
|
|
149
187
|
for (let attempt = 0; ; attempt++) {
|
|
150
188
|
stats.requests++;
|
|
151
|
-
const res = await post(body);
|
|
189
|
+
const res = await post(body, token);
|
|
152
190
|
if (res.status === 429 && attempt < 3) { await new Promise((r) => setTimeout(r, 2000 * (attempt + 1))); continue; }
|
|
153
|
-
|
|
191
|
+
// Missing rights are not an expired token: no refresh, and public tools simply retry anonymously
|
|
192
|
+
const insufficient = res.status === 403 || /insufficient_scope/i.test(res.headers.get("www-authenticate") ?? "");
|
|
193
|
+
if (insufficient && token) {
|
|
194
|
+
if (PERSONAL.has(tool)) throw scopeError(tool);
|
|
195
|
+
token = null;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (res.status === 401) {
|
|
199
|
+
// The token was refused: renew it once (another process may already have), then give up
|
|
200
|
+
if (token && !renewed && !process.env.VV_TOKEN) {
|
|
201
|
+
renewed = true;
|
|
202
|
+
try { token = await auth.refresh({ rejected: token }); continue; } catch (e) {
|
|
203
|
+
if (PERSONAL.has(tool)) throw new VVError(signInHint(e), "auth");
|
|
204
|
+
token = null;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
throw new VVError(token ? "ВкусВилл не принял вход — vv login" : "Нужен вход: vv login или Профиль → войти", "auth");
|
|
209
|
+
}
|
|
154
210
|
if (!res.ok) throw new VVError(`ВкусВилл ответил ${res.status}`, "http");
|
|
155
211
|
if (+(res.headers.get("content-length") ?? 0) > MAX_RESPONSE) throw new VVError("Слишком большой ответ", "http");
|
|
156
212
|
let text = await res.text();
|
|
@@ -166,7 +222,13 @@ async function rawCall(tool, args) {
|
|
|
166
222
|
const payload = parseJSON(content, "ответ инструмента");
|
|
167
223
|
if (env.result?.isError || payload.ok === false) {
|
|
168
224
|
const msg = clean(payload.error?.message ?? content).slice(0, 200);
|
|
169
|
-
|
|
225
|
+
const code = String(payload.error?.code ?? "");
|
|
226
|
+
if (token && /scope|forbidden|permission/i.test(`${code} ${msg}`)) {
|
|
227
|
+
if (PERSONAL.has(tool)) throw scopeError(tool);
|
|
228
|
+
token = null;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
throw new VVError(msg, /auth|token|jwt|unauthor/i.test(msg) ? "auth" : code || "rpc");
|
|
170
232
|
}
|
|
171
233
|
// Everything downstream — the UI, command output, the cache — gets strings with control characters removed
|
|
172
234
|
return cleanDeep(payload.data ?? payload);
|
|
@@ -180,22 +242,36 @@ function remember(key, value) {
|
|
|
180
242
|
while (mem.size > MEM_LIMIT) mem.delete(mem.keys().next().value);
|
|
181
243
|
}
|
|
182
244
|
|
|
245
|
+
// Whether this call will carry the user's token: then the answer is personal (stock at the user's
|
|
246
|
+
// address, recommendations) and must not be mixed with anonymous answers in the cache
|
|
247
|
+
function personal(tool) {
|
|
248
|
+
if (!OFFICIAL || PERSONAL.has(tool)) return OFFICIAL;
|
|
249
|
+
const st = auth.cachedStatus();
|
|
250
|
+
return st.signedIn && (!TOOL_SCOPE[tool] || auth.allows(TOOL_SCOPE[tool]));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const PERSONAL_TTL = 10 * 60e3; // stock changes quickly
|
|
254
|
+
|
|
183
255
|
export async function call(tool, args = {}, { prio = "hi", fresh = false } = {}) {
|
|
184
|
-
const
|
|
256
|
+
const own = personal(tool);
|
|
257
|
+
const key = JSON.stringify([tool, args, own ? "user" : "anon"]);
|
|
185
258
|
const cacheable = !NO_CACHE.has(tool);
|
|
186
|
-
if (cacheable && !fresh && mem.has(key)) {
|
|
259
|
+
if (cacheable && !fresh && mem.has(key)) {
|
|
260
|
+
const v = mem.get(key);
|
|
261
|
+
if (!own || Date.now() - v.at < PERSONAL_TTL) { stats.cacheHits++; remember(key, v); return v.data; }
|
|
262
|
+
}
|
|
187
263
|
if (inflight.has(key)) return inflight.get(key);
|
|
188
264
|
const p = (async () => {
|
|
189
265
|
const ttl = TTL[tool] ?? TTL.default;
|
|
190
|
-
|
|
266
|
+
// Personal answers live in memory only; the disk holds anonymous ones
|
|
267
|
+
if (cacheable && !fresh && !own) {
|
|
191
268
|
const hit = await diskGet("api", key, ttl);
|
|
192
|
-
if (hit !== undefined) { stats.cacheHits++; remember(key, hit); return hit; }
|
|
269
|
+
if (hit !== undefined) { stats.cacheHits++; remember(key, { data: hit, at: Date.now() }); return hit; }
|
|
193
270
|
}
|
|
194
271
|
const data = await enqueue(() => rawCall(tool, args), prio);
|
|
195
272
|
if (cacheable) {
|
|
196
|
-
remember(key, data);
|
|
197
|
-
|
|
198
|
-
if (!data?.meta?.personalized) diskPut("api", key, data);
|
|
273
|
+
remember(key, { data, at: Date.now() });
|
|
274
|
+
if (!own && !data?.meta?.personalized) diskPut("api", key, data);
|
|
199
275
|
}
|
|
200
276
|
return data;
|
|
201
277
|
})();
|
package/src/app.js
CHANGED
|
@@ -11,6 +11,7 @@ import { stats, onActivity, pruneCache, CACHE_DIR } from "./api.js";
|
|
|
11
11
|
import { onImageLoad } from "./image.js";
|
|
12
12
|
import { onUrlBlocked } from "./sys.js";
|
|
13
13
|
import { store } from "./store.js";
|
|
14
|
+
import * as auth from "./auth.js";
|
|
14
15
|
import { rub } from "./product.js";
|
|
15
16
|
|
|
16
17
|
export const TABS = [
|
|
@@ -46,6 +47,9 @@ export class App {
|
|
|
46
47
|
this.toastMsg = null;
|
|
47
48
|
this.lastClick = { t: 0, x: -1, y: -1 };
|
|
48
49
|
this.compare = new Map();
|
|
50
|
+
// Keyboard focus on the header: ↑ from the top of any page lands here, ← → walk the tabs
|
|
51
|
+
this.headerFocus = false;
|
|
52
|
+
this.headerSel = 0;
|
|
49
53
|
for (const t of TABS) this.stacks[t.id] = [views[t.id](this)];
|
|
50
54
|
const saved = store.pref("tab");
|
|
51
55
|
this.tab = TABS.some((t) => t.id === saved) ? saved : "catalog";
|
|
@@ -75,7 +79,7 @@ export class App {
|
|
|
75
79
|
log(e);
|
|
76
80
|
store.saveNow();
|
|
77
81
|
restore();
|
|
78
|
-
console.error(`vkusvill
|
|
82
|
+
console.error(`vkusvill упал: ${e?.message ?? e}\nподробности: ${LOG_FILE}`);
|
|
79
83
|
process.exit(1);
|
|
80
84
|
});
|
|
81
85
|
process.stdout.on("resize", () => { s.resize(process.stdout.columns, process.stdout.rows); this.render(); });
|
|
@@ -181,17 +185,69 @@ export class App {
|
|
|
181
185
|
this.invalidate();
|
|
182
186
|
}
|
|
183
187
|
|
|
188
|
+
// ——— Header focus ———
|
|
189
|
+
|
|
190
|
+
// Header items: the tabs, then the sign-in/sign-out button when there is one
|
|
191
|
+
headerItems() {
|
|
192
|
+
const st = auth.cachedStatus();
|
|
193
|
+
return [...TABS.map((t) => t.id), ...(st.viaEnv ? [] : ["auth"])];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
focusHeader() {
|
|
197
|
+
this.headerFocus = true;
|
|
198
|
+
this.headerSel = TABS.findIndex((t) => t.id === this.tab);
|
|
199
|
+
this.invalidate();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
leaveHeader() {
|
|
203
|
+
this.headerFocus = false;
|
|
204
|
+
this.page.focusTop?.();
|
|
205
|
+
this.invalidate();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
headerKey(ev) {
|
|
209
|
+
const items = this.headerItems();
|
|
210
|
+
const k = ev.hot ?? ev.name;
|
|
211
|
+
const move = (d) => {
|
|
212
|
+
this.headerSel = (this.headerSel + d + items.length) % items.length;
|
|
213
|
+
const id = items[this.headerSel];
|
|
214
|
+
if (id !== "auth") this.switchTab(id);
|
|
215
|
+
this.invalidate();
|
|
216
|
+
};
|
|
217
|
+
if (ev.name === "left" || (k === "h" && !ev.ctrl)) return move(-1), true;
|
|
218
|
+
if (ev.name === "right" || (k === "l" && !ev.ctrl)) return move(1), true;
|
|
219
|
+
if (ev.name === "up") return true;
|
|
220
|
+
// Digits and Tab switch tabs as everywhere, staying in the header
|
|
221
|
+
const n = Number(ev.ch);
|
|
222
|
+
if (ev.ch && n >= 1 && n <= TABS.length) return move(n - 1 - this.headerSel), true;
|
|
223
|
+
if (ev.name === "tab" || ev.name === "backtab") return move(ev.name === "tab" ? 1 : -1), true;
|
|
224
|
+
if (ev.name === "enter" && items[this.headerSel] === "auth") {
|
|
225
|
+
this.headerFocus = false;
|
|
226
|
+
auth.cachedStatus().signedIn ? this.views.signOut(this) : this.views.signIn(this);
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
if (ev.name === "down" || ev.name === "enter" || ev.name === "escape") return this.leaveHeader(), true;
|
|
230
|
+
// Anything else leaves the header and goes to the page as usual (typing straight into a search)
|
|
231
|
+
this.headerFocus = false;
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
|
|
184
235
|
// ——— Events ———
|
|
185
236
|
|
|
186
237
|
onEvent(ev) {
|
|
187
238
|
if (ev.type === "key" && ev.ctrl && ev.name === "c") return this.quit();
|
|
188
239
|
if (ev.type === "key" && ev.ctrl && ev.name === "l") { this.screen.invalidateAll(); return this.render(); }
|
|
189
|
-
if (ev.type === "mouse") return this.onMouse(ev);
|
|
240
|
+
if (ev.type === "mouse") { if (ev.action === "down") this.headerFocus = false; return this.onMouse(ev); }
|
|
190
241
|
const top = this.overlays.at(-1);
|
|
191
242
|
if (top) { top.key(ev, this); return this.invalidate(); }
|
|
192
243
|
if (ev.type === "key" && ev.ctrl && ev.name === "k") return this.views.palette(this);
|
|
244
|
+
if (this.headerFocus && ev.type === "key" && this.headerKey(ev)) return this.invalidate();
|
|
193
245
|
if (this.page.key?.(ev)) return this.invalidate();
|
|
194
246
|
if (ev.type !== "key") return;
|
|
247
|
+
// ↑ that the page did not use means it is at its top edge: go up to the header
|
|
248
|
+
if (ev.name === "up") return this.focusHeader();
|
|
249
|
+
// → opens things, so ← that the page did not use goes back out, like esc
|
|
250
|
+
if (ev.name === "left" && this.stacks[this.tab].length > 1) return this.pop();
|
|
195
251
|
if (ev.name === "escape") return this.pop();
|
|
196
252
|
const k = ev.hot ?? ev.name;
|
|
197
253
|
const n = Number(ev.ch);
|
|
@@ -267,7 +323,10 @@ export class App {
|
|
|
267
323
|
const w = g.w;
|
|
268
324
|
g.fill(0, 0, w, 1, T.header);
|
|
269
325
|
let labels = TABS.map((t, i) => ` ${i + 1} ${t.label}${t.id === "cart" && store.count ? ` ${store.count}` : ""} `);
|
|
270
|
-
|
|
326
|
+
// Signing in and out is one click away from anywhere (a ready-made VV_TOKEN is not ours to drop)
|
|
327
|
+
const st = auth.cachedStatus();
|
|
328
|
+
const signInLabel = st.viaEnv ? null : st.signedIn ? " выйти " : " войти ";
|
|
329
|
+
const cartW = (store.count ? strWidth(` ▣ ${rub(store.total)} `) + 1 : 0) + (signInLabel ? strWidth(signInLabel) + 1 : 0);
|
|
271
330
|
let tabsW = labels.reduce((sum, l) => sum + strWidth(l), 0);
|
|
272
331
|
// Too narrow: drop the leading space of every tab, then the wordmark
|
|
273
332
|
if (4 + tabsW + cartW > w) { labels = labels.map((l) => l.slice(1)); tabsW -= labels.length; }
|
|
@@ -279,24 +338,32 @@ export class App {
|
|
|
279
338
|
}
|
|
280
339
|
x = g.text(x, 0, "▶", { fg: T.greenD, bg: T.header });
|
|
281
340
|
if (full) x += 1;
|
|
341
|
+
const focusId = this.headerFocus ? this.headerItems()[this.headerSel] : null;
|
|
282
342
|
TABS.forEach((t, i) => {
|
|
283
343
|
const active = t.id === this.tab;
|
|
284
344
|
const label = labels[i];
|
|
285
345
|
if (x + strWidth(label) > w - cartW) return;
|
|
286
346
|
const x0 = x;
|
|
287
|
-
|
|
347
|
+
// A focused tab is brighter and underlined, so keyboard focus is visible at a glance
|
|
348
|
+
const focused = focusId === t.id;
|
|
349
|
+
x = g.text(x, 0, label, { fg: active ? T.bg : T.sub, bg: focused ? T.lime : active ? T.green : T.header, bold: active, underline: focused });
|
|
288
350
|
this.hit(x0, 0, x - x0, 1, { click: () => this.switchTab(t.id) });
|
|
289
351
|
});
|
|
290
|
-
// Right side: network activity and the cart total
|
|
352
|
+
// Right side: network activity, sign-in and the cart total
|
|
291
353
|
let rx = w - 1;
|
|
292
354
|
if (store.count) {
|
|
293
355
|
const t = ` ▣ ${rub(store.total)} `;
|
|
294
356
|
rx = g.textR(rx + 1, 0, t, { fg: T.bg, bg: T.lime, bold: true }) - 1;
|
|
295
357
|
this.hit(rx + 1, 0, strWidth(t), 1, { click: () => this.switchTab("cart") });
|
|
296
358
|
}
|
|
359
|
+
if (signInLabel) {
|
|
360
|
+
const focused = focusId === "auth";
|
|
361
|
+
rx = g.textR(rx + 1, 0, signInLabel, { fg: focused ? T.bg : st.signedIn ? T.sub : T.lime, bg: focused ? T.lime : T.raise, bold: !st.signedIn || focused, underline: focused }) - 2;
|
|
362
|
+
this.hit(rx + 2, 0, strWidth(signInLabel), 1, { click: () => (st.signedIn ? this.views.signOut(this) : this.views.signIn(this)) });
|
|
363
|
+
}
|
|
297
364
|
const room = Math.max(0, rx - x - 1);
|
|
298
365
|
if (stats.active || stats.queued || this.page.busy?.()) {
|
|
299
|
-
g.textR(rx, 0, truncate(`${SPIN[this.frame % SPIN.length]} ${stats.queued ? `очередь ${stats.queued}` : "
|
|
366
|
+
g.textR(rx, 0, truncate(`${SPIN[this.frame % SPIN.length]} ${stats.queued ? `очередь ${stats.queued}` : "загрузка"}`, room), { fg: T.lime, bg: T.header });
|
|
300
367
|
} else if (store.saveError) {
|
|
301
368
|
g.textR(rx, 0, truncate(`⚠ не сохранилось: ${store.saveError}`, Math.min(room, 50)), { fg: T.red, bg: T.header });
|
|
302
369
|
} else if (stats.lastError) {
|
|
@@ -308,7 +375,8 @@ export class App {
|
|
|
308
375
|
const y = g.h - 1;
|
|
309
376
|
g.fill(0, y, g.w, 1, T.header);
|
|
310
377
|
const top = this.overlays.at(-1);
|
|
311
|
-
const
|
|
378
|
+
const headerHints = [["← →", "вкладки"], ["↓ ⏎", "в раздел"]];
|
|
379
|
+
const hints = top?.hints?.() ?? (this.headerFocus ? headerHints : this.page.hints?.() ?? []);
|
|
312
380
|
const all = [...hints, ...(top ? [] : [["?", "помощь"], ["^K", "команды"]])];
|
|
313
381
|
// A toast takes the right end of the line; hints never run under it
|
|
314
382
|
let toastW = 0;
|