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.
Files changed (111) hide show
  1. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/.gitignore +7 -0
  2. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/PKG-INFO +1 -1
  3. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/pyproject.toml +2 -1
  4. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/main.py +362 -20
  5. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/kimi.py +19 -4
  6. kimi_code_usage-0.1.6/src/kimi_code_usage/server.py +851 -0
  7. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/test_main.py +223 -2
  8. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/test_provider_kimi.py +40 -0
  9. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/uv.lock +1 -1
  10. kimi_code_usage-0.1.4/htmlcov/.gitignore +0 -2
  11. kimi_code_usage-0.1.4/htmlcov/class_index.html +0 -281
  12. kimi_code_usage-0.1.4/htmlcov/coverage_html_cb_dd2e7eb5.js +0 -735
  13. kimi_code_usage-0.1.4/htmlcov/favicon_32_cb_c827f16f.png +0 -0
  14. kimi_code_usage-0.1.4/htmlcov/function_index.html +0 -771
  15. kimi_code_usage-0.1.4/htmlcov/index.html +0 -189
  16. kimi_code_usage-0.1.4/htmlcov/keybd_closed_cb_900cfef5.png +0 -0
  17. kimi_code_usage-0.1.4/htmlcov/status.json +0 -1
  18. kimi_code_usage-0.1.4/htmlcov/style_cb_4667309f.css +0 -391
  19. kimi_code_usage-0.1.4/htmlcov/z_581a990e2fe7e610___init___py.html +0 -97
  20. kimi_code_usage-0.1.4/htmlcov/z_581a990e2fe7e610_config_py.html +0 -298
  21. kimi_code_usage-0.1.4/htmlcov/z_581a990e2fe7e610_main_py.html +0 -1202
  22. kimi_code_usage-0.1.4/htmlcov/z_581a990e2fe7e610_mcp_py.html +0 -272
  23. kimi_code_usage-0.1.4/htmlcov/z_aaf3485d2243f1af___init___py.html +0 -203
  24. kimi_code_usage-0.1.4/htmlcov/z_aaf3485d2243f1af_anthropic_py.html +0 -167
  25. kimi_code_usage-0.1.4/htmlcov/z_aaf3485d2243f1af_kimi_py.html +0 -256
  26. kimi_code_usage-0.1.4/htmlcov/z_aaf3485d2243f1af_openai_py.html +0 -173
  27. kimi_code_usage-0.1.4/htmlcov/z_aaf3485d2243f1af_openrouter_py.html +0 -463
  28. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/.claude/settings.local.json +0 -0
  29. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/.env.example +0 -0
  30. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/.github/workflows/ci.yml +0 -0
  31. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/AGENTS.md +0 -0
  32. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/README.md +0 -0
  33. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/README.zh.md +0 -0
  34. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/docs/kimi-code-usage.md +0 -0
  35. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/docs/superpowers/plans/2026-05-09-pace-indicator.md +0 -0
  36. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/docs/superpowers/plans/2026-05-14-pace-theme-thresholds.md +0 -0
  37. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/docs/superpowers/plans/2026-06-10-multi-llm-quota.md +0 -0
  38. {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
  39. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/docs/superpowers/specs/2026-05-09-pace-indicator-design.md +0 -0
  40. {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
  41. {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
  42. {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
  43. {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
  44. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/kimi_usage.code-workspace +0 -0
  45. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/requirements.txt +0 -0
  46. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/__init__.py +0 -0
  47. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/config.py +0 -0
  48. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/mcp.py +0 -0
  49. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/__init__.py +0 -0
  50. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/anthropic.py +0 -0
  51. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/openai.py +0 -0
  52. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/openrouter.py +0 -0
  53. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/__init__.py +0 -0
  54. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/conftest.py +0 -0
  55. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/test_config.py +0 -0
  56. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/test_mcp.py +0 -0
  57. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/test_provider_anthropic.py +0 -0
  58. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/test_provider_openai.py +0 -0
  59. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/test_provider_openrouter.py +0 -0
  60. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/tests/test_providers_init.py +0 -0
  61. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/.claude/settings.local.json +0 -0
  62. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/.github/workflows/ci.yml +0 -0
  63. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/.prettierignore +0 -0
  64. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/.prettierrc.json +0 -0
  65. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/.vscodeignore +0 -0
  66. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/LICENSE +0 -0
  67. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/README.md +0 -0
  68. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/assets/icon.png +0 -0
  69. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/assets/moon.svg +0 -0
  70. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/eslint.config.mjs +0 -0
  71. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.de.json +0 -0
  72. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.en.json +0 -0
  73. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.es.json +0 -0
  74. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.fr.json +0 -0
  75. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.it.json +0 -0
  76. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.ja.json +0 -0
  77. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.ko.json +0 -0
  78. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.pt.json +0 -0
  79. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.ru.json +0 -0
  80. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.zh-cn.json +0 -0
  81. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/package.json +0 -0
  82. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/scripts/copy-assets.ts +0 -0
  83. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/__mocks__/vscode.ts +0 -0
  84. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/api.test.ts +0 -0
  85. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/api.ts +0 -0
  86. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/apiCache.test.ts +0 -0
  87. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/apiCache.ts +0 -0
  88. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/config.test.ts +0 -0
  89. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/config.ts +0 -0
  90. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/extension.test.ts +0 -0
  91. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/extension.ts +0 -0
  92. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/historyPanel.test.ts +0 -0
  93. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/historyPanel.ts +0 -0
  94. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/i18n.test.ts +0 -0
  95. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/i18n.ts +0 -0
  96. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/notifier.test.ts +0 -0
  97. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/notifier.ts +0 -0
  98. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/pace.test.ts +0 -0
  99. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/pace.ts +0 -0
  100. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/predict.test.ts +0 -0
  101. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/predict.ts +0 -0
  102. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/statusBar.test.ts +0 -0
  103. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/statusBar.ts +0 -0
  104. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/storage-unlink.test.ts +0 -0
  105. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/storage.test.ts +0 -0
  106. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/storage.ts +0 -0
  107. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/types.ts +0 -0
  108. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/src/vscode-mock.test.ts +0 -0
  109. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/tsconfig.eslint.json +0 -0
  110. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/tsconfig.json +0 -0
  111. {kimi_code_usage-0.1.4 → kimi_code_usage-0.1.6}/vscode-extension/vitest.config.ts +0 -0
@@ -44,3 +44,10 @@ vscode-extension/coverage/
44
44
  .vscode-test/
45
45
  **/*.js.map
46
46
  tokscale-export-*.json
47
+
48
+ # Toolchain caches
49
+ .ruff_cache/
50
+ .mypy_cache/
51
+
52
+ # Coverage
53
+ htmlcov/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kimi-code-usage
3
- Version: 0.1.4
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.4"
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
- others_color_idx = len(top_models) % len(_model_colors(theme))
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 = colors[cidx % len(colors)]
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 = colors[i % len(colors)]
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 _render_top_models(top_models, lang_zh: bool, theme: dict, metric: str = OR_METRIC_REQUESTS, chart_width: int = 20) -> Text:
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
- body_text.append(_render_top_models(row.top_models, lang_zh, theme, metric=or_metric, chart_width=20))
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
- async def _interactive_mode(config: "AppConfig", initial_theme: str) -> None:
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
- 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)
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, refresh_per_second=4) as live:
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
- ch = f'\x1b[{ch3}'
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 [/] to cycle themes, r refresh, q quit")
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)