kimi-code-usage 0.1.5__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.5 → kimi_code_usage-0.1.6}/.gitignore +7 -0
  2. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/PKG-INFO +1 -1
  3. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/pyproject.toml +2 -1
  4. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/main.py +189 -17
  5. kimi_code_usage-0.1.6/src/kimi_code_usage/server.py +851 -0
  6. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/test_main.py +157 -2
  7. kimi_code_usage-0.1.5/htmlcov/.gitignore +0 -2
  8. kimi_code_usage-0.1.5/htmlcov/class_index.html +0 -281
  9. kimi_code_usage-0.1.5/htmlcov/coverage_html_cb_dd2e7eb5.js +0 -735
  10. kimi_code_usage-0.1.5/htmlcov/favicon_32_cb_c827f16f.png +0 -0
  11. kimi_code_usage-0.1.5/htmlcov/function_index.html +0 -771
  12. kimi_code_usage-0.1.5/htmlcov/index.html +0 -189
  13. kimi_code_usage-0.1.5/htmlcov/keybd_closed_cb_900cfef5.png +0 -0
  14. kimi_code_usage-0.1.5/htmlcov/status.json +0 -1
  15. kimi_code_usage-0.1.5/htmlcov/style_cb_4667309f.css +0 -391
  16. kimi_code_usage-0.1.5/htmlcov/z_581a990e2fe7e610___init___py.html +0 -97
  17. kimi_code_usage-0.1.5/htmlcov/z_581a990e2fe7e610_config_py.html +0 -298
  18. kimi_code_usage-0.1.5/htmlcov/z_581a990e2fe7e610_main_py.html +0 -1202
  19. kimi_code_usage-0.1.5/htmlcov/z_581a990e2fe7e610_mcp_py.html +0 -272
  20. kimi_code_usage-0.1.5/htmlcov/z_aaf3485d2243f1af___init___py.html +0 -203
  21. kimi_code_usage-0.1.5/htmlcov/z_aaf3485d2243f1af_anthropic_py.html +0 -167
  22. kimi_code_usage-0.1.5/htmlcov/z_aaf3485d2243f1af_kimi_py.html +0 -256
  23. kimi_code_usage-0.1.5/htmlcov/z_aaf3485d2243f1af_openai_py.html +0 -173
  24. kimi_code_usage-0.1.5/htmlcov/z_aaf3485d2243f1af_openrouter_py.html +0 -463
  25. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/.claude/settings.local.json +0 -0
  26. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/.env.example +0 -0
  27. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/.github/workflows/ci.yml +0 -0
  28. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/AGENTS.md +0 -0
  29. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/README.md +0 -0
  30. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/README.zh.md +0 -0
  31. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/docs/kimi-code-usage.md +0 -0
  32. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/docs/superpowers/plans/2026-05-09-pace-indicator.md +0 -0
  33. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/docs/superpowers/plans/2026-05-14-pace-theme-thresholds.md +0 -0
  34. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/docs/superpowers/plans/2026-06-10-multi-llm-quota.md +0 -0
  35. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/docs/superpowers/plans/2026-06-10-vscode-extension-ts-strictness.md +0 -0
  36. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/docs/superpowers/specs/2026-05-09-pace-indicator-design.md +0 -0
  37. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/docs/superpowers/specs/2026-05-14-pace-theme-thresholds-design.md +0 -0
  38. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/docs/superpowers/specs/2026-06-10-multi-llm-quota-design.md +0 -0
  39. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/docs/superpowers/specs/2026-06-10-vscode-extension-multi-provider-design.md +0 -0
  40. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/docs/superpowers/specs/2026-06-10-vscode-extension-ts-refactor-design.md +0 -0
  41. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/kimi_usage.code-workspace +0 -0
  42. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/requirements.txt +0 -0
  43. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/__init__.py +0 -0
  44. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/config.py +0 -0
  45. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/mcp.py +0 -0
  46. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/__init__.py +0 -0
  47. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/anthropic.py +0 -0
  48. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/kimi.py +0 -0
  49. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/openai.py +0 -0
  50. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/openrouter.py +0 -0
  51. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/__init__.py +0 -0
  52. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/conftest.py +0 -0
  53. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/test_config.py +0 -0
  54. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/test_mcp.py +0 -0
  55. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/test_provider_anthropic.py +0 -0
  56. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/test_provider_kimi.py +0 -0
  57. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/test_provider_openai.py +0 -0
  58. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/test_provider_openrouter.py +0 -0
  59. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/test_providers_init.py +0 -0
  60. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/uv.lock +0 -0
  61. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/.claude/settings.local.json +0 -0
  62. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/.github/workflows/ci.yml +0 -0
  63. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/.prettierignore +0 -0
  64. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/.prettierrc.json +0 -0
  65. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/.vscodeignore +0 -0
  66. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/LICENSE +0 -0
  67. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/README.md +0 -0
  68. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/assets/icon.png +0 -0
  69. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/assets/moon.svg +0 -0
  70. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/eslint.config.mjs +0 -0
  71. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.de.json +0 -0
  72. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.en.json +0 -0
  73. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.es.json +0 -0
  74. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.fr.json +0 -0
  75. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.it.json +0 -0
  76. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.ja.json +0 -0
  77. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.ko.json +0 -0
  78. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.pt.json +0 -0
  79. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.ru.json +0 -0
  80. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.zh-cn.json +0 -0
  81. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/package.json +0 -0
  82. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/scripts/copy-assets.ts +0 -0
  83. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/__mocks__/vscode.ts +0 -0
  84. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/api.test.ts +0 -0
  85. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/api.ts +0 -0
  86. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/apiCache.test.ts +0 -0
  87. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/apiCache.ts +0 -0
  88. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/config.test.ts +0 -0
  89. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/config.ts +0 -0
  90. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/extension.test.ts +0 -0
  91. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/extension.ts +0 -0
  92. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/historyPanel.test.ts +0 -0
  93. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/historyPanel.ts +0 -0
  94. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/i18n.test.ts +0 -0
  95. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/i18n.ts +0 -0
  96. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/notifier.test.ts +0 -0
  97. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/notifier.ts +0 -0
  98. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/pace.test.ts +0 -0
  99. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/pace.ts +0 -0
  100. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/predict.test.ts +0 -0
  101. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/predict.ts +0 -0
  102. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/statusBar.test.ts +0 -0
  103. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/statusBar.ts +0 -0
  104. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/storage-unlink.test.ts +0 -0
  105. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/storage.test.ts +0 -0
  106. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/storage.ts +0 -0
  107. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/types.ts +0 -0
  108. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/vscode-mock.test.ts +0 -0
  109. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/tsconfig.eslint.json +0 -0
  110. {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/tsconfig.json +0 -0
  111. {kimi_code_usage-0.1.5 → 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.5
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.5"
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"]
@@ -12,7 +12,8 @@ from rich.panel import Panel
12
12
  from rich.text import Text
13
13
 
14
14
  from kimi_code_usage.config import DEFAULT_CONFIG_PATH, AppConfig, ConfigResolver, save_theme
15
- from kimi_code_usage.providers import DailyUsage, ProviderUsage, dispatch_all
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")
@@ -557,7 +558,7 @@ def _render_activity_totals(totals, lang_zh: bool, theme: dict) -> Text:
557
558
  return text
558
559
 
559
560
 
560
- 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:
561
562
  if not daily_activity:
562
563
  return Text()
563
564
  metric = _parse_or_metric(metric)
@@ -611,7 +612,16 @@ def _render_daily_chart(daily_activity, lang_zh: bool, theme: dict, metric: str
611
612
  model_totals_all[model.model] = model_totals_all.get(model.model, 0.0) + _metric_value_model(model, metric)
612
613
  top_models = _top_n_models(model_totals_all, top_models_limit)
613
614
  top_model_set = set(top_models)
614
- 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`
615
625
 
616
626
  # Pre-compute each column as color indices (bottom -> top)
617
627
  columns: list[list[int | None]] = []
@@ -671,8 +681,6 @@ def _render_daily_chart(daily_activity, lang_zh: bool, theme: dict, metric: str
671
681
  filled_glyph = "▐" + "█" * (col_width - 1)
672
682
  empty_glyph = " " * col_width
673
683
 
674
- colors = _model_colors(theme)
675
-
676
684
  # Render rows top -> bottom
677
685
  for row in range(height - 1, -1, -1):
678
686
  text.append(chart_indent, style=theme.get("meta", "grey62"))
@@ -689,7 +697,7 @@ def _render_daily_chart(daily_activity, lang_zh: bool, theme: dict, metric: str
689
697
  for col in columns:
690
698
  cidx = col[row]
691
699
  if cidx is not None:
692
- color = colors[cidx % len(colors)]
700
+ color = palette[cidx]
693
701
  text.append(filled_glyph, style=color)
694
702
  else:
695
703
  text.append(empty_glyph)
@@ -740,7 +748,7 @@ def _render_daily_chart(daily_activity, lang_zh: bool, theme: dict, metric: str
740
748
  text.append(" ", style=theme.get("meta", "grey62"))
741
749
  for i, model in enumerate(top_models[:8]):
742
750
  name = truncate(_model_short_name(model), 14)
743
- color = colors[i % len(colors)]
751
+ color = palette[i]
744
752
  text.append("█", style=color)
745
753
  text.append(f" {name} ", style=theme.get("meta", "grey62"))
746
754
  text.append("\n")
@@ -763,6 +771,44 @@ def _model_colors(theme: dict) -> list[str]:
763
771
  ]
764
772
 
765
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
+
766
812
  def _top_n_models(model_totals: dict[str, float], n: int) -> list[str]:
767
813
  items = sorted(model_totals.items(), key=lambda x: (-x[1], x[0]))
768
814
  return [m for m, _ in items[:n]]
@@ -781,7 +827,54 @@ def truncate(s: str, max_len: int) -> str:
781
827
 
782
828
 
783
829
 
784
- 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:
785
878
  if not top_models:
786
879
  return Text()
787
880
  metric = _parse_or_metric(metric)
@@ -804,7 +897,7 @@ def _render_top_models(top_models, lang_zh: bool, theme: dict, metric: str = OR_
804
897
  name = _model_short_name(m.model)
805
898
  if len(name) > 22:
806
899
  name = name[:19] + "..."
807
- color = colors[i % len(colors)]
900
+ color = color_map.get(m.model, colors[i % len(colors)]) if color_map else colors[i % len(colors)]
808
901
  text.append(f" {name:22} ", style=theme.get("meta", "grey62"))
809
902
  text.append(f"{_format_metric_value(val, metric):>8} ", style=theme.get("meta", "grey62"))
810
903
  text.append("█" * bar_len, style=color)
@@ -856,6 +949,11 @@ def _format_aggregated_results(
856
949
  bar_width = 20
857
950
 
858
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)
859
957
  for i, row in enumerate(p_items):
860
958
  if i > 0:
861
959
  body_text.append("\n")
@@ -871,11 +969,12 @@ def _format_aggregated_results(
871
969
  if row.daily_activity:
872
970
  if i > 0 or row.activity_totals:
873
971
  body_text.append("\n")
874
- 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))
875
973
  if row.top_models:
876
974
  if i > 0 or row.activity_totals or row.daily_activity:
877
975
  body_text.append("\n")
878
- 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))
879
978
  continue
880
979
 
881
980
  loc_label = _get_localized_label(row.label, lang_zh)
@@ -978,6 +1077,35 @@ def _format_aggregated_results(
978
1077
  return result
979
1078
 
980
1079
 
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
+
981
1109
  async def _interactive_mode(config: "AppConfig", initial_theme: str, config_path: str | None = None) -> None:
982
1110
  """Live Rich TUI with keyboard theme cycling and provider panel toggling.
983
1111
 
@@ -1017,6 +1145,7 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str, config_path
1017
1145
  current_view = "usage"
1018
1146
 
1019
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)
1020
1149
 
1021
1150
  _SHORT = {
1022
1151
  "anthropic": "Anthropic", "openai": "Openai",
@@ -1058,6 +1187,7 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str, config_path
1058
1187
  ("[c]", "配置" if lang_zh else "config"),
1059
1188
  ("[←/→]", "主题" if lang_zh else "theme"),
1060
1189
  ("[1-4]", "面板" if lang_zh else "panels"),
1190
+ ("[↑↓]", "滚动" if lang_zh else "scroll"),
1061
1191
  ("[l]", "英" if lang_zh else "ZH"),
1062
1192
  ("[m]", _metric_label(or_metric, lang_zh)),
1063
1193
  ("[d]", f"{days_window}d"),
@@ -1072,6 +1202,19 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str, config_path
1072
1202
  hint.append(f" ✓ {'已保存' if lang_zh else 'Saved'}: {saved_notice[0]}", style="bold green")
1073
1203
  hint.justify = "center"
1074
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
+
1075
1218
  panel_content = Group(
1076
1219
  top_bar,
1077
1220
  Text(""),
@@ -1102,7 +1245,7 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str, config_path
1102
1245
  tty.setcbreak(fd)
1103
1246
  results, errors = await dispatch_all(config)
1104
1247
  console = Console()
1105
- 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:
1106
1249
  running = True
1107
1250
  while running:
1108
1251
  rlist, _, _ = _select_module.select([sys.stdin], [], [], 0.15)
@@ -1119,17 +1262,25 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str, config_path
1119
1262
  r3, _, _ = _select_module.select([sys.stdin], [], [], 0.05)
1120
1263
  if r3:
1121
1264
  ch3 = _read_char()
1122
- 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}'
1123
1272
 
1124
1273
  if saved_notice[0]: # clear notice on any new keypress
1125
1274
  saved_notice[0] = None
1126
1275
  if ch in ('h', 'H', '?'):
1127
1276
  current_view = "usage" if current_view == "help" else "help"
1128
- live.update(_build_panel())
1277
+ scroll[0] = 0
1278
+ live.update(_build_panel(), refresh=True)
1129
1279
  continue
1130
1280
  if ch in ('c', 'C'):
1131
1281
  current_view = "usage" if current_view == "config" else "config"
1132
- live.update(_build_panel())
1282
+ scroll[0] = 0
1283
+ live.update(_build_panel(), refresh=True)
1133
1284
  continue
1134
1285
  if ch in ('\r', '\n'): # Enter → save settings to config file
1135
1286
  save_theme(
@@ -1140,7 +1291,7 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str, config_path
1140
1291
  days_window=days_window
1141
1292
  )
1142
1293
  saved_notice[0] = themes[idx]
1143
- live.update(_build_panel())
1294
+ live.update(_build_panel(), refresh=True)
1144
1295
  # Flush any consecutive \r or \n (like \r\n from terminal)
1145
1296
  while True:
1146
1297
  r_extra, _, _ = _select_module.select([sys.stdin], [], [], 0.0)
@@ -1150,11 +1301,25 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str, config_path
1150
1301
  continue
1151
1302
  break
1152
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
1153
1317
  idx, quit_flag, refresh_flag, toggle_num, lang_toggle, metric_toggle, days_toggle = _handle_key(ch, idx, len(themes))
1154
1318
  if quit_flag:
1155
1319
  running = False
1156
1320
  elif refresh_flag:
1157
1321
  results, errors = await dispatch_all(config)
1322
+ scroll[0] = 0
1158
1323
  elif toggle_num is not None:
1159
1324
  providers = list(config.provider_order)
1160
1325
  if toggle_num < len(providers):
@@ -1169,7 +1334,7 @@ async def _interactive_mode(config: "AppConfig", initial_theme: str, config_path
1169
1334
  or_metric = _next_or_metric(or_metric)
1170
1335
  elif days_toggle:
1171
1336
  days_window = _next_days_window(days_window)
1172
- live.update(_build_panel())
1337
+ live.update(_build_panel(), refresh=True)
1173
1338
  await asyncio.sleep(0)
1174
1339
  finally:
1175
1340
  termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
@@ -1185,11 +1350,18 @@ async def main():
1185
1350
  parser.add_argument("--provider", help="Comma-separated providers to query (kimi,openai,anthropic,openrouter)")
1186
1351
  parser.add_argument("--config", help="Custom configuration file path")
1187
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)")
1188
1355
  args = parser.parse_args()
1189
1356
 
1190
1357
  resolver = ConfigResolver(config_path=args.config)
1191
1358
  config = resolver.resolve()
1192
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
+
1193
1365
  # Determine theme
1194
1366
  theme_name = args.theme if args.theme else config.theme
1195
1367