kimi-code-usage 0.1.4__tar.gz → 0.1.6__tar.gz
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.
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/.gitignore +7 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/PKG-INFO +1 -1
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/pyproject.toml +2 -1
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/main.py +362 -20
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/kimi.py +19 -4
- kimi_code_usage-0.1.6/src/kimi_code_usage/server.py +851 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/test_main.py +223 -2
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/test_provider_kimi.py +40 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/uv.lock +1 -1
- kimi_code_usage-0.1.4/htmlcov/.gitignore +0 -2
- kimi_code_usage-0.1.4/htmlcov/class_index.html +0 -281
- kimi_code_usage-0.1.4/htmlcov/coverage_html_cb_dd2e7eb5.js +0 -735
- kimi_code_usage-0.1.4/htmlcov/favicon_32_cb_c827f16f.png +0 -0
- kimi_code_usage-0.1.4/htmlcov/function_index.html +0 -771
- kimi_code_usage-0.1.4/htmlcov/index.html +0 -189
- kimi_code_usage-0.1.4/htmlcov/keybd_closed_cb_900cfef5.png +0 -0
- kimi_code_usage-0.1.4/htmlcov/status.json +0 -1
- kimi_code_usage-0.1.4/htmlcov/style_cb_4667309f.css +0 -391
- kimi_code_usage-0.1.4/htmlcov/z_581a990e2fe7e610___init___py.html +0 -97
- kimi_code_usage-0.1.4/htmlcov/z_581a990e2fe7e610_config_py.html +0 -298
- kimi_code_usage-0.1.4/htmlcov/z_581a990e2fe7e610_main_py.html +0 -1202
- kimi_code_usage-0.1.4/htmlcov/z_581a990e2fe7e610_mcp_py.html +0 -272
- kimi_code_usage-0.1.4/htmlcov/z_aaf3485d2243f1af___init___py.html +0 -203
- kimi_code_usage-0.1.4/htmlcov/z_aaf3485d2243f1af_anthropic_py.html +0 -167
- kimi_code_usage-0.1.4/htmlcov/z_aaf3485d2243f1af_kimi_py.html +0 -256
- kimi_code_usage-0.1.4/htmlcov/z_aaf3485d2243f1af_openai_py.html +0 -173
- kimi_code_usage-0.1.4/htmlcov/z_aaf3485d2243f1af_openrouter_py.html +0 -463
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/.claude/settings.local.json +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/.env.example +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/.github/workflows/ci.yml +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/AGENTS.md +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/README.md +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/README.zh.md +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/docs/kimi-code-usage.md +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/docs/superpowers/plans/2026-05-09-pace-indicator.md +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/docs/superpowers/plans/2026-05-14-pace-theme-thresholds.md +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/docs/superpowers/plans/2026-06-10-multi-llm-quota.md +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/docs/superpowers/plans/2026-06-10-vscode-extension-ts-strictness.md +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/docs/superpowers/specs/2026-05-09-pace-indicator-design.md +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/docs/superpowers/specs/2026-05-14-pace-theme-thresholds-design.md +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/docs/superpowers/specs/2026-06-10-multi-llm-quota-design.md +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/docs/superpowers/specs/2026-06-10-vscode-extension-multi-provider-design.md +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/docs/superpowers/specs/2026-06-10-vscode-extension-ts-refactor-design.md +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/kimi_usage.code-workspace +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/requirements.txt +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/__init__.py +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/config.py +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/mcp.py +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/__init__.py +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/anthropic.py +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/openai.py +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/openrouter.py +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/__init__.py +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/conftest.py +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/test_config.py +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/test_mcp.py +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/test_provider_anthropic.py +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/test_provider_openai.py +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/test_provider_openrouter.py +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/test_providers_init.py +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/.claude/settings.local.json +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/.github/workflows/ci.yml +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/.prettierignore +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/.prettierrc.json +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/.vscodeignore +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/LICENSE +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/README.md +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/assets/icon.png +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/assets/moon.svg +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/eslint.config.mjs +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.de.json +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.en.json +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.es.json +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.fr.json +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.it.json +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.ja.json +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.ko.json +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.pt.json +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.ru.json +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.zh-cn.json +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/package.json +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/scripts/copy-assets.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/__mocks__/vscode.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/api.test.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/api.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/apiCache.test.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/apiCache.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/config.test.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/config.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/extension.test.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/extension.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/historyPanel.test.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/historyPanel.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/i18n.test.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/i18n.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/notifier.test.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/notifier.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/pace.test.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/pace.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/predict.test.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/predict.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/statusBar.test.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/statusBar.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/storage-unlink.test.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/storage.test.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/storage.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/types.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/vscode-mock.test.ts +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/tsconfig.eslint.json +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/tsconfig.json +0 -0
- {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/vitest.config.ts +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kimi-code-usage
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: A curated Kimi Coding Plan usage monitor with aesthetic CLI and MCP server.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Golden0Voyager/kimi-code-usage
|
|
6
6
|
Project-URL: Repository, https://github.com/Golden0Voyager/kimi-code-usage
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "kimi-code-usage"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.6"
|
|
4
4
|
description = "A curated Kimi Coding Plan usage monitor with aesthetic CLI and MCP server."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -59,6 +59,7 @@ ignore = ["E501", "SIM108", "SIM117", "UP035", "N806"]
|
|
|
59
59
|
|
|
60
60
|
[tool.mypy]
|
|
61
61
|
ignore_missing_imports = true
|
|
62
|
+
files = ["src/kimi_code_usage/"]
|
|
62
63
|
|
|
63
64
|
[tool.coverage.run]
|
|
64
65
|
source = ["kimi_code_usage"]
|
|
@@ -11,8 +11,9 @@ from rich.live import Live
|
|
|
11
11
|
from rich.panel import Panel
|
|
12
12
|
from rich.text import Text
|
|
13
13
|
|
|
14
|
-
from kimi_code_usage.config import AppConfig, ConfigResolver, save_theme
|
|
15
|
-
from kimi_code_usage.providers import DailyUsage, ProviderUsage, dispatch_all
|
|
14
|
+
from kimi_code_usage.config import DEFAULT_CONFIG_PATH, AppConfig, ConfigResolver, save_theme
|
|
15
|
+
from kimi_code_usage.providers import DailyUsage, ModelUsage, ProviderUsage, dispatch_all
|
|
16
|
+
from kimi_code_usage.server import run_server
|
|
16
17
|
|
|
17
18
|
# --- i18n ---
|
|
18
19
|
LANG = os.getenv("LANG", "en")
|
|
@@ -42,6 +43,41 @@ L_ZH = {
|
|
|
42
43
|
|
|
43
44
|
L = L_ZH if IS_ZH else L_EN
|
|
44
45
|
|
|
46
|
+
_PROVIDER_SETUP_HELP = {
|
|
47
|
+
"kimi": {
|
|
48
|
+
"name": "Kimi Code",
|
|
49
|
+
"key_envs": ("KIMI_API_KEY", "KIMI_CODING_API_KEY"),
|
|
50
|
+
"base_env": "KIMI_BASE_URL",
|
|
51
|
+
"default_url": "https://api.kimi.com/coding/v1",
|
|
52
|
+
"note_en": "Use a Kimi Code / Coding Plan key (sk-kimi-...). Do not use https://api.moonshot.cn/v1.",
|
|
53
|
+
"note_zh": "使用 Kimi Code / Coding Plan 密钥(sk-kimi-...),不要使用 https://api.moonshot.cn/v1。",
|
|
54
|
+
},
|
|
55
|
+
"openai": {
|
|
56
|
+
"name": "OpenAI",
|
|
57
|
+
"key_envs": ("OPENAI_API_KEY",),
|
|
58
|
+
"base_env": "OPENAI_BASE_URL",
|
|
59
|
+
"default_url": "https://api.openai.com",
|
|
60
|
+
"note_en": "Use an OpenAI API key with organization usage access.",
|
|
61
|
+
"note_zh": "使用具备用量接口权限的 OpenAI API Key。",
|
|
62
|
+
},
|
|
63
|
+
"anthropic": {
|
|
64
|
+
"name": "Anthropic",
|
|
65
|
+
"key_envs": ("ANTHROPIC_API_KEY",),
|
|
66
|
+
"base_env": "ANTHROPIC_BASE_URL",
|
|
67
|
+
"default_url": "https://api.anthropic.com",
|
|
68
|
+
"note_en": "Use an Anthropic key that can access the usage endpoint.",
|
|
69
|
+
"note_zh": "使用具备用量接口权限的 Anthropic Key。",
|
|
70
|
+
},
|
|
71
|
+
"openrouter": {
|
|
72
|
+
"name": "OpenRouter",
|
|
73
|
+
"key_envs": ("OPENROUTER_API_KEY", "OPENROUTER_ADMIN_KEY"),
|
|
74
|
+
"base_env": "OPENROUTER_BASE_URL",
|
|
75
|
+
"default_url": "https://openrouter.ai/api",
|
|
76
|
+
"note_en": "Set OPENROUTER_MANAGEMENT_KEY or OPENROUTER_ADMIN_KEY for activity details.",
|
|
77
|
+
"note_zh": "如需活动明细,设置 OPENROUTER_MANAGEMENT_KEY 或 OPENROUTER_ADMIN_KEY。",
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
|
|
45
81
|
def _get_visual_width(s: str) -> int:
|
|
46
82
|
import unicodedata
|
|
47
83
|
width = 0
|
|
@@ -52,6 +88,14 @@ def _get_visual_width(s: str) -> int:
|
|
|
52
88
|
width += 1
|
|
53
89
|
return width
|
|
54
90
|
|
|
91
|
+
|
|
92
|
+
def _mask_secret(secret: str | None) -> str:
|
|
93
|
+
if not secret:
|
|
94
|
+
return "missing"
|
|
95
|
+
if len(secret) <= 8:
|
|
96
|
+
return secret[:2] + "..."
|
|
97
|
+
return f"{secret[:7]}...{secret[-4:]}"
|
|
98
|
+
|
|
55
99
|
def _get_localized_label(label: str, lang_zh: bool = IS_ZH) -> str:
|
|
56
100
|
_L = L_ZH if lang_zh else L_EN
|
|
57
101
|
|
|
@@ -155,6 +199,117 @@ def _get_localized_text_value(text_val: str, lang_zh: bool) -> str:
|
|
|
155
199
|
|
|
156
200
|
return text_val
|
|
157
201
|
|
|
202
|
+
|
|
203
|
+
def _render_interactive_help(lang_zh: bool, or_metric: str = "requests", days_window: int = 30) -> Text:
|
|
204
|
+
text = Text()
|
|
205
|
+
title = "交互帮助" if lang_zh else "Interactive Help"
|
|
206
|
+
text.append(f"{title}\n\n", style="bold")
|
|
207
|
+
if lang_zh:
|
|
208
|
+
rows = [
|
|
209
|
+
("q / Ctrl-C", "退出"),
|
|
210
|
+
("r", "刷新用量"),
|
|
211
|
+
("h / ?", "显示或隐藏帮助"),
|
|
212
|
+
("c", "显示或隐藏配置引导"),
|
|
213
|
+
("1-4", "切换服务商面板"),
|
|
214
|
+
("l", "切换中英文"),
|
|
215
|
+
("←/→ 或 [ / ]", "切换主题"),
|
|
216
|
+
("m", f"切换 OpenRouter 指标(当前:{_metric_label(or_metric, True)})"),
|
|
217
|
+
("d", f"切换 OpenRouter 图表窗口(当前:{days_window}d)"),
|
|
218
|
+
("Enter", "保存主题、语言、可见面板和 OpenRouter 显示偏好"),
|
|
219
|
+
]
|
|
220
|
+
footer = "再次按 h/? 或 c 可回到用量视图。"
|
|
221
|
+
else:
|
|
222
|
+
rows = [
|
|
223
|
+
("q / Ctrl-C", "quit"),
|
|
224
|
+
("r", "refresh usage"),
|
|
225
|
+
("h / ?", "show or hide help"),
|
|
226
|
+
("c", "show or hide configuration guide"),
|
|
227
|
+
("1-4", "toggle provider panels"),
|
|
228
|
+
("l", "toggle language"),
|
|
229
|
+
("←/→ or [ / ]", "cycle theme"),
|
|
230
|
+
("m", f"cycle OpenRouter metric (current: {_metric_label(or_metric, False)})"),
|
|
231
|
+
("d", f"cycle OpenRouter chart window (current: {days_window}d)"),
|
|
232
|
+
("Enter", "save theme, language, visible panels, and OpenRouter display preferences"),
|
|
233
|
+
]
|
|
234
|
+
footer = "Press h/? or c again to return to the usage view."
|
|
235
|
+
|
|
236
|
+
for key, desc in rows:
|
|
237
|
+
text.append(f" {key:<16}", style="bold")
|
|
238
|
+
text.append(f"{desc}\n", style="grey62")
|
|
239
|
+
text.append(f"\n{footer}", style="grey62")
|
|
240
|
+
return text
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _render_config_guide(
|
|
244
|
+
config: AppConfig,
|
|
245
|
+
lang_zh: bool = IS_ZH,
|
|
246
|
+
config_path: str | None = None,
|
|
247
|
+
) -> Text:
|
|
248
|
+
text = Text()
|
|
249
|
+
title = "配置引导" if lang_zh else "Configuration Guide"
|
|
250
|
+
path = config_path or str(DEFAULT_CONFIG_PATH)
|
|
251
|
+
text.append(f"{title}\n\n", style="bold")
|
|
252
|
+
if lang_zh:
|
|
253
|
+
text.append(f"配置文件: {path}\n", style="grey62")
|
|
254
|
+
text.append("读取优先级: config.json > 环境变量 / 当前目录 .env > 默认 base URL\n\n", style="grey62")
|
|
255
|
+
else:
|
|
256
|
+
text.append(f"Config file: {path}\n", style="grey62")
|
|
257
|
+
text.append("Resolution order: config.json > environment variables / current .env > default base URL\n\n", style="grey62")
|
|
258
|
+
|
|
259
|
+
provider_order = list(config.provider_order)
|
|
260
|
+
for provider_name in _PROVIDER_SETUP_HELP:
|
|
261
|
+
if provider_name not in provider_order:
|
|
262
|
+
provider_order.append(provider_name)
|
|
263
|
+
|
|
264
|
+
for provider_name in provider_order:
|
|
265
|
+
help_info = _PROVIDER_SETUP_HELP.get(provider_name)
|
|
266
|
+
if not help_info:
|
|
267
|
+
continue
|
|
268
|
+
p_conf = config.providers.get(provider_name)
|
|
269
|
+
api_key = p_conf.api_key if p_conf else None
|
|
270
|
+
base_url = p_conf.base_url if p_conf and p_conf.base_url else help_info["default_url"]
|
|
271
|
+
enabled = p_conf.enabled if p_conf else True
|
|
272
|
+
status = "enabled" if api_key and enabled else ("disabled" if not enabled else "missing")
|
|
273
|
+
if lang_zh:
|
|
274
|
+
status_label = {"enabled": "已启用", "disabled": "已禁用", "missing": "缺少密钥"}[status]
|
|
275
|
+
text.append(f"{help_info['name']}\n", style="bold")
|
|
276
|
+
text.append(f" 状态: {status_label}\n", style="grey62")
|
|
277
|
+
text.append(f" Key: {_mask_secret(api_key)} ({' / '.join(help_info['key_envs'])})\n", style="grey62")
|
|
278
|
+
text.append(f" Base URL: {base_url} ({help_info['base_env']})\n", style="grey62")
|
|
279
|
+
text.append(f" 提示: {help_info['note_zh']}\n", style="grey62")
|
|
280
|
+
else:
|
|
281
|
+
text.append(f"{help_info['name']}\n", style="bold")
|
|
282
|
+
text.append(f" status: {status}\n", style="grey62")
|
|
283
|
+
text.append(f" key: {_mask_secret(api_key)} ({' / '.join(help_info['key_envs'])})\n", style="grey62")
|
|
284
|
+
text.append(f" baseUrl: {base_url} ({help_info['base_env']})\n", style="grey62")
|
|
285
|
+
text.append(f" note: {help_info['note_en']}\n", style="grey62")
|
|
286
|
+
|
|
287
|
+
if provider_name == "openrouter":
|
|
288
|
+
management_key = p_conf.management_key if p_conf else None
|
|
289
|
+
if lang_zh:
|
|
290
|
+
text.append(
|
|
291
|
+
f" 管理 Key: {_mask_secret(management_key)} (OPENROUTER_MANAGEMENT_KEY / OPENROUTER_ADMIN_KEY)\n",
|
|
292
|
+
style="grey62",
|
|
293
|
+
)
|
|
294
|
+
else:
|
|
295
|
+
text.append(
|
|
296
|
+
f" management key: {_mask_secret(management_key)} (OPENROUTER_MANAGEMENT_KEY / OPENROUTER_ADMIN_KEY)\n",
|
|
297
|
+
style="grey62",
|
|
298
|
+
)
|
|
299
|
+
text.append("\n")
|
|
300
|
+
|
|
301
|
+
if lang_zh:
|
|
302
|
+
text.append(
|
|
303
|
+
"建议把长期配置写入 ~/.kimi-usage/config.json;uvx 每次运行都会读取这个文件。\n",
|
|
304
|
+
style="grey62",
|
|
305
|
+
)
|
|
306
|
+
else:
|
|
307
|
+
text.append(
|
|
308
|
+
"For persistent uvx usage, put long-lived settings in ~/.kimi-usage/config.json.\n",
|
|
309
|
+
style="grey62",
|
|
310
|
+
)
|
|
311
|
+
return text
|
|
312
|
+
|
|
158
313
|
# (typing already imported at top)
|
|
159
314
|
|
|
160
315
|
THEME_MAP = {
|
|
@@ -403,7 +558,7 @@ def _render_activity_totals(totals, lang_zh: bool, theme: dict) -> Text:
|
|
|
403
558
|
return text
|
|
404
559
|
|
|
405
560
|
|
|
406
|
-
def _render_daily_chart(daily_activity, lang_zh: bool, theme: dict, metric: str = OR_METRIC_REQUESTS, days_window: int = 30) -> Text:
|
|
561
|
+
def _render_daily_chart(daily_activity, lang_zh: bool, theme: dict, metric: str = OR_METRIC_REQUESTS, days_window: int = 30, color_map: dict[str, str] | None = None) -> Text:
|
|
407
562
|
if not daily_activity:
|
|
408
563
|
return Text()
|
|
409
564
|
metric = _parse_or_metric(metric)
|
|
@@ -457,7 +612,16 @@ def _render_daily_chart(daily_activity, lang_zh: bool, theme: dict, metric: str
|
|
|
457
612
|
model_totals_all[model.model] = model_totals_all.get(model.model, 0.0) + _metric_value_model(model, metric)
|
|
458
613
|
top_models = _top_n_models(model_totals_all, top_models_limit)
|
|
459
614
|
top_model_set = set(top_models)
|
|
460
|
-
|
|
615
|
+
# Build a color palette aligned to `top_models`, plus a trailing neutral color
|
|
616
|
+
# for the "others" bucket. When a shared color_map is supplied, colors are
|
|
617
|
+
# keyed by model name so this chart agrees with the Top Models chart.
|
|
618
|
+
_colors = _model_colors(theme)
|
|
619
|
+
if color_map is not None:
|
|
620
|
+
palette = [color_map.get(name, _colors[i % len(_colors)]) for i, name in enumerate(top_models)]
|
|
621
|
+
else:
|
|
622
|
+
palette = [_colors[i % len(_colors)] for i in range(len(top_models))]
|
|
623
|
+
palette.append(theme.get("meta", "grey62")) # "others" bucket → neutral
|
|
624
|
+
others_color_idx = len(top_models) # index of the "others" color in `palette`
|
|
461
625
|
|
|
462
626
|
# Pre-compute each column as color indices (bottom -> top)
|
|
463
627
|
columns: list[list[int | None]] = []
|
|
@@ -517,8 +681,6 @@ def _render_daily_chart(daily_activity, lang_zh: bool, theme: dict, metric: str
|
|
|
517
681
|
filled_glyph = "▐" + "█" * (col_width - 1)
|
|
518
682
|
empty_glyph = " " * col_width
|
|
519
683
|
|
|
520
|
-
colors = _model_colors(theme)
|
|
521
|
-
|
|
522
684
|
# Render rows top -> bottom
|
|
523
685
|
for row in range(height - 1, -1, -1):
|
|
524
686
|
text.append(chart_indent, style=theme.get("meta", "grey62"))
|
|
@@ -535,7 +697,7 @@ def _render_daily_chart(daily_activity, lang_zh: bool, theme: dict, metric: str
|
|
|
535
697
|
for col in columns:
|
|
536
698
|
cidx = col[row]
|
|
537
699
|
if cidx is not None:
|
|
538
|
-
color =
|
|
700
|
+
color = palette[cidx]
|
|
539
701
|
text.append(filled_glyph, style=color)
|
|
540
702
|
else:
|
|
541
703
|
text.append(empty_glyph)
|
|
@@ -586,7 +748,7 @@ def _render_daily_chart(daily_activity, lang_zh: bool, theme: dict, metric: str
|
|
|
586
748
|
text.append(" ", style=theme.get("meta", "grey62"))
|
|
587
749
|
for i, model in enumerate(top_models[:8]):
|
|
588
750
|
name = truncate(_model_short_name(model), 14)
|
|
589
|
-
color =
|
|
751
|
+
color = palette[i]
|
|
590
752
|
text.append("█", style=color)
|
|
591
753
|
text.append(f" {name} ", style=theme.get("meta", "grey62"))
|
|
592
754
|
text.append("\n")
|
|
@@ -609,6 +771,44 @@ def _model_colors(theme: dict) -> list[str]:
|
|
|
609
771
|
]
|
|
610
772
|
|
|
611
773
|
|
|
774
|
+
def _build_model_color_map(model_names: list[str], theme: dict) -> dict[str, str]:
|
|
775
|
+
"""Map each model name to a stable color so every panel colors it the same.
|
|
776
|
+
|
|
777
|
+
``model_names`` is expected in canonical (metric-descending) order, so the
|
|
778
|
+
top-ranked model gets the first color. Colors cycle when there are more
|
|
779
|
+
models than palette entries.
|
|
780
|
+
"""
|
|
781
|
+
colors = _model_colors(theme)
|
|
782
|
+
return {name: colors[i % len(colors)] for i, name in enumerate(model_names)}
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _build_openrouter_color_map(rows, metric: str, theme: dict) -> dict[str, str] | None:
|
|
786
|
+
"""Build one shared model→color map for an OpenRouter provider's rows.
|
|
787
|
+
|
|
788
|
+
The daily chart and the Top Models chart live in separate ``ProviderUsage``
|
|
789
|
+
rows but must agree on colors. We rank models by the currently displayed
|
|
790
|
+
metric (preferring the aggregate ``top_models`` row, else ``daily_activity``)
|
|
791
|
+
so the same model gets the same color in both. Returns ``None`` when the
|
|
792
|
+
rows carry no model breakdown (e.g. non-OpenRouter providers).
|
|
793
|
+
"""
|
|
794
|
+
metric = _parse_or_metric(metric)
|
|
795
|
+
totals: dict[str, float] = {}
|
|
796
|
+
top_row = next((r for r in rows if r.top_models), None)
|
|
797
|
+
if top_row and top_row.top_models:
|
|
798
|
+
for m in top_row.top_models:
|
|
799
|
+
totals[m.model] = totals.get(m.model, 0.0) + _metric_value_model(m, metric)
|
|
800
|
+
else:
|
|
801
|
+
for row in rows:
|
|
802
|
+
if row.daily_activity:
|
|
803
|
+
for day in row.daily_activity:
|
|
804
|
+
for m in day.models:
|
|
805
|
+
totals[m.model] = totals.get(m.model, 0.0) + _metric_value_model(m, metric)
|
|
806
|
+
if not totals:
|
|
807
|
+
return None
|
|
808
|
+
ordered = [name for name, _ in sorted(totals.items(), key=lambda kv: (-kv[1], kv[0]))]
|
|
809
|
+
return _build_model_color_map(ordered, theme)
|
|
810
|
+
|
|
811
|
+
|
|
612
812
|
def _top_n_models(model_totals: dict[str, float], n: int) -> list[str]:
|
|
613
813
|
items = sorted(model_totals.items(), key=lambda x: (-x[1], x[0]))
|
|
614
814
|
return [m for m, _ in items[:n]]
|
|
@@ -627,7 +827,54 @@ def truncate(s: str, max_len: int) -> str:
|
|
|
627
827
|
|
|
628
828
|
|
|
629
829
|
|
|
630
|
-
def
|
|
830
|
+
def _window_top_models(daily_activity, days_window: int, metric: str) -> list[ModelUsage]:
|
|
831
|
+
"""Rank models within the current [d] window, so Top Models tracks the Daily chart.
|
|
832
|
+
|
|
833
|
+
Mirrors the daily chart's window (a contiguous range of ``days_window`` days
|
|
834
|
+
ending at the latest activity date), aggregates each model's usage across those
|
|
835
|
+
days, and returns them ranked by ``metric`` descending. Returns an empty list
|
|
836
|
+
when there is no dated activity to window over.
|
|
837
|
+
"""
|
|
838
|
+
if not daily_activity:
|
|
839
|
+
return []
|
|
840
|
+
from datetime import datetime, timedelta
|
|
841
|
+
|
|
842
|
+
metric = _parse_or_metric(metric)
|
|
843
|
+
days_window = _parse_days_window(days_window)
|
|
844
|
+
sorted_days = sorted(daily_activity, key=lambda d: d.date)
|
|
845
|
+
latest_date = datetime.strptime(sorted_days[-1].date[:10], "%Y-%m-%d")
|
|
846
|
+
start_date = latest_date - timedelta(days=days_window - 1)
|
|
847
|
+
|
|
848
|
+
agg: dict[str, ModelUsage] = {}
|
|
849
|
+
for day in sorted_days:
|
|
850
|
+
key = day.date[:10] if len(day.date) >= 10 else day.date
|
|
851
|
+
try:
|
|
852
|
+
day_date = datetime.strptime(key, "%Y-%m-%d")
|
|
853
|
+
except ValueError:
|
|
854
|
+
continue
|
|
855
|
+
if day_date < start_date or day_date > latest_date:
|
|
856
|
+
continue
|
|
857
|
+
for m in day.models:
|
|
858
|
+
existing = agg.get(m.model)
|
|
859
|
+
if existing is None:
|
|
860
|
+
agg[m.model] = ModelUsage(
|
|
861
|
+
model=m.model,
|
|
862
|
+
spend=m.spend,
|
|
863
|
+
requests=m.requests,
|
|
864
|
+
prompt_tokens=m.prompt_tokens,
|
|
865
|
+
completion_tokens=m.completion_tokens,
|
|
866
|
+
reasoning_tokens=m.reasoning_tokens,
|
|
867
|
+
)
|
|
868
|
+
else:
|
|
869
|
+
existing.spend += m.spend
|
|
870
|
+
existing.requests += m.requests
|
|
871
|
+
existing.prompt_tokens += m.prompt_tokens
|
|
872
|
+
existing.completion_tokens += m.completion_tokens
|
|
873
|
+
existing.reasoning_tokens += m.reasoning_tokens
|
|
874
|
+
return sorted(agg.values(), key=lambda m: (-_metric_value_model(m, metric), m.model))
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _render_top_models(top_models, lang_zh: bool, theme: dict, metric: str = OR_METRIC_REQUESTS, chart_width: int = 20, color_map: dict[str, str] | None = None) -> Text:
|
|
631
878
|
if not top_models:
|
|
632
879
|
return Text()
|
|
633
880
|
metric = _parse_or_metric(metric)
|
|
@@ -650,7 +897,7 @@ def _render_top_models(top_models, lang_zh: bool, theme: dict, metric: str = OR_
|
|
|
650
897
|
name = _model_short_name(m.model)
|
|
651
898
|
if len(name) > 22:
|
|
652
899
|
name = name[:19] + "..."
|
|
653
|
-
color = colors[i % len(colors)]
|
|
900
|
+
color = color_map.get(m.model, colors[i % len(colors)]) if color_map else colors[i % len(colors)]
|
|
654
901
|
text.append(f" {name:22} ", style=theme.get("meta", "grey62"))
|
|
655
902
|
text.append(f"{_format_metric_value(val, metric):>8} ", style=theme.get("meta", "grey62"))
|
|
656
903
|
text.append("█" * bar_len, style=color)
|
|
@@ -702,6 +949,11 @@ def _format_aggregated_results(
|
|
|
702
949
|
bar_width = 20
|
|
703
950
|
|
|
704
951
|
body_text = Text()
|
|
952
|
+
# Shared model→color map so the daily chart and Top Models agree on colors.
|
|
953
|
+
model_color_map = _build_openrouter_color_map(p_items, or_metric, theme)
|
|
954
|
+
# Top Models tracks the [d] window: recompute the ranking from the dated
|
|
955
|
+
# daily activity so switching the window updates it (consistent with the chart).
|
|
956
|
+
_daily_row = next((r for r in p_items if r.daily_activity), None)
|
|
705
957
|
for i, row in enumerate(p_items):
|
|
706
958
|
if i > 0:
|
|
707
959
|
body_text.append("\n")
|
|
@@ -717,11 +969,12 @@ def _format_aggregated_results(
|
|
|
717
969
|
if row.daily_activity:
|
|
718
970
|
if i > 0 or row.activity_totals:
|
|
719
971
|
body_text.append("\n")
|
|
720
|
-
body_text.append(_render_daily_chart(row.daily_activity, lang_zh, theme, metric=or_metric, days_window=days_window))
|
|
972
|
+
body_text.append(_render_daily_chart(row.daily_activity, lang_zh, theme, metric=or_metric, days_window=days_window, color_map=model_color_map))
|
|
721
973
|
if row.top_models:
|
|
722
974
|
if i > 0 or row.activity_totals or row.daily_activity:
|
|
723
975
|
body_text.append("\n")
|
|
724
|
-
|
|
976
|
+
_windowed = _window_top_models(_daily_row.daily_activity, days_window, or_metric) if _daily_row else []
|
|
977
|
+
body_text.append(_render_top_models(_windowed or row.top_models, lang_zh, theme, metric=or_metric, chart_width=20, color_map=model_color_map))
|
|
725
978
|
continue
|
|
726
979
|
|
|
727
980
|
loc_label = _get_localized_label(row.label, lang_zh)
|
|
@@ -824,7 +1077,36 @@ def _format_aggregated_results(
|
|
|
824
1077
|
return result
|
|
825
1078
|
|
|
826
1079
|
|
|
827
|
-
|
|
1080
|
+
def _fit_scroll_body(body: Text, budget: int, scroll_offset: int, lang_zh: bool) -> tuple[Text, int]:
|
|
1081
|
+
"""Clamp a body ``Text`` to ``budget`` lines with an in-panel scroll offset.
|
|
1082
|
+
|
|
1083
|
+
Keeps the interactive panel within the terminal height so the control hint
|
|
1084
|
+
bar is never cropped and Rich never has to scroll the screen (which would
|
|
1085
|
+
duplicate the header). When the body overflows, returns the visible slice
|
|
1086
|
+
plus a scroll-indicator line; otherwise returns the body unchanged. The
|
|
1087
|
+
second return value is the clamped offset the caller should store back.
|
|
1088
|
+
"""
|
|
1089
|
+
if budget < 1:
|
|
1090
|
+
budget = 1
|
|
1091
|
+
lines = body.split("\n")
|
|
1092
|
+
total = len(lines)
|
|
1093
|
+
if total <= budget:
|
|
1094
|
+
return body, 0
|
|
1095
|
+
view_rows = max(1, budget - 1) # reserve one line for the scroll indicator
|
|
1096
|
+
max_off = max(0, total - view_rows)
|
|
1097
|
+
off = max(0, min(scroll_offset, max_off))
|
|
1098
|
+
visible = list(lines[off:off + view_rows])
|
|
1099
|
+
up = "▲" if off > 0 else "·"
|
|
1100
|
+
down = "▼" if off + view_rows < total else "·"
|
|
1101
|
+
label = "滚动" if lang_zh else "scroll"
|
|
1102
|
+
indicator = Text(
|
|
1103
|
+
f" {up} {off + 1}–{min(off + view_rows, total)}/{total} {down} [↑/↓ PgUp/PgDn {label}]",
|
|
1104
|
+
style="dim",
|
|
1105
|
+
)
|
|
1106
|
+
return Text("\n").join([*visible, indicator]), off
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
async def _interactive_mode(config: "AppConfig", initial_theme: str, config_path: str | None = None) -> None:
|
|
828
1110
|
"""Live Rich TUI with keyboard theme cycling and provider panel toggling.
|
|
829
1111
|
|
|
830
1112
|
Keys:
|
|
@@ -860,8 +1142,10 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str) -> None:
|
|
|
860
1142
|
|
|
861
1143
|
or_metric: str = _parse_or_metric(config.or_metric)
|
|
862
1144
|
days_window: int = _parse_days_window(config.days_window)
|
|
1145
|
+
current_view = "usage"
|
|
863
1146
|
|
|
864
1147
|
saved_notice: list = [None] # holds the saved theme name briefly, then None
|
|
1148
|
+
scroll: list = [0] # in-panel vertical scroll offset (lines from top)
|
|
865
1149
|
|
|
866
1150
|
_SHORT = {
|
|
867
1151
|
"anthropic": "Anthropic", "openai": "Openai",
|
|
@@ -871,7 +1155,12 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str) -> None:
|
|
|
871
1155
|
def _build_panel() -> Panel:
|
|
872
1156
|
visible_order = [p for p in config.provider_order if p in visible_providers]
|
|
873
1157
|
_L = L_ZH if lang_zh else L_EN
|
|
874
|
-
|
|
1158
|
+
if current_view == "help":
|
|
1159
|
+
body = _render_interactive_help(lang_zh, or_metric=or_metric, days_window=days_window)
|
|
1160
|
+
elif current_view == "config":
|
|
1161
|
+
body = _render_config_guide(config, lang_zh=lang_zh, config_path=config_path)
|
|
1162
|
+
else:
|
|
1163
|
+
body = _format_aggregated_results(results, errors, visible_order, themes[idx], lang_zh, enabled_providers=set(config.enabled_providers), or_metric=or_metric, days_window=days_window)
|
|
875
1164
|
|
|
876
1165
|
top_bar = Text()
|
|
877
1166
|
# Provider toggles
|
|
@@ -894,8 +1183,11 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str) -> None:
|
|
|
894
1183
|
bindings = [
|
|
895
1184
|
("[q]", "退出" if lang_zh else "quit"),
|
|
896
1185
|
("[r]", "刷新" if lang_zh else "refresh"),
|
|
1186
|
+
("[h/?]", "帮助" if lang_zh else "help"),
|
|
1187
|
+
("[c]", "配置" if lang_zh else "config"),
|
|
897
1188
|
("[←/→]", "主题" if lang_zh else "theme"),
|
|
898
1189
|
("[1-4]", "面板" if lang_zh else "panels"),
|
|
1190
|
+
("[↑↓]", "滚动" if lang_zh else "scroll"),
|
|
899
1191
|
("[l]", "英" if lang_zh else "ZH"),
|
|
900
1192
|
("[m]", _metric_label(or_metric, lang_zh)),
|
|
901
1193
|
("[d]", f"{days_window}d"),
|
|
@@ -910,6 +1202,19 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str) -> None:
|
|
|
910
1202
|
hint.append(f" ✓ {'已保存' if lang_zh else 'Saved'}: {saved_notice[0]}", style="bold green")
|
|
911
1203
|
hint.justify = "center"
|
|
912
1204
|
|
|
1205
|
+
# Keep top_bar and hint to a single line each so the fit math below is exact.
|
|
1206
|
+
top_bar.no_wrap = True
|
|
1207
|
+
top_bar.overflow = "ellipsis"
|
|
1208
|
+
hint.no_wrap = True
|
|
1209
|
+
hint.overflow = "ellipsis"
|
|
1210
|
+
|
|
1211
|
+
# Fit the body to the terminal so the hint bar is never cropped and Rich
|
|
1212
|
+
# never scrolls the screen (which duplicates the header). Overhead:
|
|
1213
|
+
# border(2) + padding(2) + top_bar(1) + gap(1) + hint(1) = 7 lines.
|
|
1214
|
+
body_budget = max(3, console.size.height - 7)
|
|
1215
|
+
if isinstance(body, Text):
|
|
1216
|
+
body, scroll[0] = _fit_scroll_body(body, body_budget, scroll[0], lang_zh)
|
|
1217
|
+
|
|
913
1218
|
panel_content = Group(
|
|
914
1219
|
top_bar,
|
|
915
1220
|
Text(""),
|
|
@@ -940,7 +1245,7 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str) -> None:
|
|
|
940
1245
|
tty.setcbreak(fd)
|
|
941
1246
|
results, errors = await dispatch_all(config)
|
|
942
1247
|
console = Console()
|
|
943
|
-
with Live(_build_panel(), console=console,
|
|
1248
|
+
with Live(_build_panel(), console=console, auto_refresh=False, vertical_overflow="crop") as live:
|
|
944
1249
|
running = True
|
|
945
1250
|
while running:
|
|
946
1251
|
rlist, _, _ = _select_module.select([sys.stdin], [], [], 0.15)
|
|
@@ -957,10 +1262,26 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str) -> None:
|
|
|
957
1262
|
r3, _, _ = _select_module.select([sys.stdin], [], [], 0.05)
|
|
958
1263
|
if r3:
|
|
959
1264
|
ch3 = _read_char()
|
|
960
|
-
|
|
1265
|
+
if ch3.isdigit():
|
|
1266
|
+
# CSI sequences like PgUp (\x1b[5~) / PgDn (\x1b[6~)
|
|
1267
|
+
r4, _, _ = _select_module.select([sys.stdin], [], [], 0.05)
|
|
1268
|
+
tilde = _read_char() if r4 else ""
|
|
1269
|
+
ch = f'\x1b[{ch3}{tilde}'
|
|
1270
|
+
else:
|
|
1271
|
+
ch = f'\x1b[{ch3}'
|
|
961
1272
|
|
|
962
1273
|
if saved_notice[0]: # clear notice on any new keypress
|
|
963
1274
|
saved_notice[0] = None
|
|
1275
|
+
if ch in ('h', 'H', '?'):
|
|
1276
|
+
current_view = "usage" if current_view == "help" else "help"
|
|
1277
|
+
scroll[0] = 0
|
|
1278
|
+
live.update(_build_panel(), refresh=True)
|
|
1279
|
+
continue
|
|
1280
|
+
if ch in ('c', 'C'):
|
|
1281
|
+
current_view = "usage" if current_view == "config" else "config"
|
|
1282
|
+
scroll[0] = 0
|
|
1283
|
+
live.update(_build_panel(), refresh=True)
|
|
1284
|
+
continue
|
|
964
1285
|
if ch in ('\r', '\n'): # Enter → save settings to config file
|
|
965
1286
|
save_theme(
|
|
966
1287
|
themes[idx],
|
|
@@ -970,7 +1291,7 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str) -> None:
|
|
|
970
1291
|
days_window=days_window
|
|
971
1292
|
)
|
|
972
1293
|
saved_notice[0] = themes[idx]
|
|
973
|
-
live.update(_build_panel())
|
|
1294
|
+
live.update(_build_panel(), refresh=True)
|
|
974
1295
|
# Flush any consecutive \r or \n (like \r\n from terminal)
|
|
975
1296
|
while True:
|
|
976
1297
|
r_extra, _, _ = _select_module.select([sys.stdin], [], [], 0.0)
|
|
@@ -980,11 +1301,25 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str) -> None:
|
|
|
980
1301
|
continue
|
|
981
1302
|
break
|
|
982
1303
|
continue
|
|
1304
|
+
if ch == '\x1b[A': # ↑ → scroll up
|
|
1305
|
+
scroll[0] = max(0, scroll[0] - 3)
|
|
1306
|
+
live.update(_build_panel(), refresh=True)
|
|
1307
|
+
continue
|
|
1308
|
+
if ch == '\x1b[B': # ↓ → scroll down
|
|
1309
|
+
scroll[0] += 3
|
|
1310
|
+
live.update(_build_panel(), refresh=True)
|
|
1311
|
+
continue
|
|
1312
|
+
if ch in ('\x1b[5~', '\x1b[6~'): # PgUp / PgDn → page scroll
|
|
1313
|
+
page = max(1, console.size.height - 8)
|
|
1314
|
+
scroll[0] = max(0, scroll[0] + (page if ch == '\x1b[6~' else -page))
|
|
1315
|
+
live.update(_build_panel(), refresh=True)
|
|
1316
|
+
continue
|
|
983
1317
|
idx, quit_flag, refresh_flag, toggle_num, lang_toggle, metric_toggle, days_toggle = _handle_key(ch, idx, len(themes))
|
|
984
1318
|
if quit_flag:
|
|
985
1319
|
running = False
|
|
986
1320
|
elif refresh_flag:
|
|
987
1321
|
results, errors = await dispatch_all(config)
|
|
1322
|
+
scroll[0] = 0
|
|
988
1323
|
elif toggle_num is not None:
|
|
989
1324
|
providers = list(config.provider_order)
|
|
990
1325
|
if toggle_num < len(providers):
|
|
@@ -999,7 +1334,7 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str) -> None:
|
|
|
999
1334
|
or_metric = _next_or_metric(or_metric)
|
|
1000
1335
|
elif days_toggle:
|
|
1001
1336
|
days_window = _next_days_window(days_window)
|
|
1002
|
-
live.update(_build_panel())
|
|
1337
|
+
live.update(_build_panel(), refresh=True)
|
|
1003
1338
|
await asyncio.sleep(0)
|
|
1004
1339
|
finally:
|
|
1005
1340
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
@@ -1011,15 +1346,22 @@ async def main():
|
|
|
1011
1346
|
parser.add_argument("--json", action="store_true")
|
|
1012
1347
|
parser.add_argument("--plain", action="store_true")
|
|
1013
1348
|
parser.add_argument("-i", "--interactive", action="store_true",
|
|
1014
|
-
help="Interactive mode: ←/→ or [/]
|
|
1349
|
+
help="Interactive mode: h help, c config, ←/→ or [/] themes, r refresh, q quit")
|
|
1015
1350
|
parser.add_argument("--provider", help="Comma-separated providers to query (kimi,openai,anthropic,openrouter)")
|
|
1016
1351
|
parser.add_argument("--config", help="Custom configuration file path")
|
|
1017
1352
|
parser.add_argument("--theme", help="Specify color theme: blue-dark, blue-light, sky-dark, salmon-dark, turquoise-dark, pink-light, violet-dark, amber-dark, mint-dark, monochrome, blind-deuteranopia, blind-tritanopia")
|
|
1353
|
+
parser.add_argument("--serve", action="store_true", help="Start web server instead of CLI output")
|
|
1354
|
+
parser.add_argument("--port", type=int, default=8765, help="Web server port (default: 8765)")
|
|
1018
1355
|
args = parser.parse_args()
|
|
1019
1356
|
|
|
1020
1357
|
resolver = ConfigResolver(config_path=args.config)
|
|
1021
1358
|
config = resolver.resolve()
|
|
1022
1359
|
|
|
1360
|
+
# Serve mode: start web server
|
|
1361
|
+
if args.serve:
|
|
1362
|
+
await run_server(port=args.port, lang_zh=IS_ZH)
|
|
1363
|
+
return
|
|
1364
|
+
|
|
1023
1365
|
# Determine theme
|
|
1024
1366
|
theme_name = args.theme if args.theme else config.theme
|
|
1025
1367
|
|
|
@@ -1034,7 +1376,7 @@ async def main():
|
|
|
1034
1376
|
|
|
1035
1377
|
# Interactive mode: hand off to TUI loop (handles its own dispatch)
|
|
1036
1378
|
if args.interactive:
|
|
1037
|
-
await _interactive_mode(config, theme_name)
|
|
1379
|
+
await _interactive_mode(config, theme_name, config_path=str(resolver.config_path))
|
|
1038
1380
|
return
|
|
1039
1381
|
|
|
1040
1382
|
results, errors = await dispatch_all(config)
|