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.
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/.gitignore +7 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/PKG-INFO +1 -1
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/pyproject.toml +2 -1
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/main.py +189 -17
- kimi_code_usage-0.1.6/src/kimi_code_usage/server.py +851 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/test_main.py +157 -2
- kimi_code_usage-0.1.5/htmlcov/.gitignore +0 -2
- kimi_code_usage-0.1.5/htmlcov/class_index.html +0 -281
- kimi_code_usage-0.1.5/htmlcov/coverage_html_cb_dd2e7eb5.js +0 -735
- kimi_code_usage-0.1.5/htmlcov/favicon_32_cb_c827f16f.png +0 -0
- kimi_code_usage-0.1.5/htmlcov/function_index.html +0 -771
- kimi_code_usage-0.1.5/htmlcov/index.html +0 -189
- kimi_code_usage-0.1.5/htmlcov/keybd_closed_cb_900cfef5.png +0 -0
- kimi_code_usage-0.1.5/htmlcov/status.json +0 -1
- kimi_code_usage-0.1.5/htmlcov/style_cb_4667309f.css +0 -391
- kimi_code_usage-0.1.5/htmlcov/z_581a990e2fe7e610___init___py.html +0 -97
- kimi_code_usage-0.1.5/htmlcov/z_581a990e2fe7e610_config_py.html +0 -298
- kimi_code_usage-0.1.5/htmlcov/z_581a990e2fe7e610_main_py.html +0 -1202
- kimi_code_usage-0.1.5/htmlcov/z_581a990e2fe7e610_mcp_py.html +0 -272
- kimi_code_usage-0.1.5/htmlcov/z_aaf3485d2243f1af___init___py.html +0 -203
- kimi_code_usage-0.1.5/htmlcov/z_aaf3485d2243f1af_anthropic_py.html +0 -167
- kimi_code_usage-0.1.5/htmlcov/z_aaf3485d2243f1af_kimi_py.html +0 -256
- kimi_code_usage-0.1.5/htmlcov/z_aaf3485d2243f1af_openai_py.html +0 -173
- kimi_code_usage-0.1.5/htmlcov/z_aaf3485d2243f1af_openrouter_py.html +0 -463
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/.claude/settings.local.json +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/.env.example +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/.github/workflows/ci.yml +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/AGENTS.md +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/README.md +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/README.zh.md +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/docs/kimi-code-usage.md +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/docs/superpowers/plans/2026-05-09-pace-indicator.md +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/docs/superpowers/plans/2026-05-14-pace-theme-thresholds.md +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/docs/superpowers/plans/2026-06-10-multi-llm-quota.md +0 -0
- {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
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/docs/superpowers/specs/2026-05-09-pace-indicator-design.md +0 -0
- {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
- {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
- {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
- {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
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/kimi_usage.code-workspace +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/requirements.txt +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/__init__.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/config.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/mcp.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/__init__.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/anthropic.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/kimi.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/openai.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/src/kimi_code_usage/providers/openrouter.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/__init__.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/conftest.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/test_config.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/test_mcp.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/test_provider_anthropic.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/test_provider_kimi.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/test_provider_openai.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/test_provider_openrouter.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/tests/test_providers_init.py +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/uv.lock +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/.claude/settings.local.json +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/.github/workflows/ci.yml +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/.prettierignore +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/.prettierrc.json +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/.vscodeignore +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/LICENSE +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/README.md +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/assets/icon.png +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/assets/moon.svg +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/eslint.config.mjs +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.de.json +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.en.json +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.es.json +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.fr.json +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.it.json +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.ja.json +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.ko.json +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.pt.json +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.ru.json +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/l10n/bundle.l10n.zh-cn.json +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/package.json +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/scripts/copy-assets.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/__mocks__/vscode.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/api.test.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/api.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/apiCache.test.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/apiCache.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/config.test.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/config.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/extension.test.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/extension.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/historyPanel.test.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/historyPanel.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/i18n.test.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/i18n.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/notifier.test.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/notifier.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/pace.test.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/pace.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/predict.test.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/predict.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/statusBar.test.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/statusBar.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/storage-unlink.test.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/storage.test.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/storage.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/types.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/src/vscode-mock.test.ts +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/tsconfig.eslint.json +0 -0
- {kimi_code_usage-0.1.5 → kimi_code_usage-0.1.6}/vscode-extension/tsconfig.json +0 -0
- {kimi_code_usage-0.1.5 → 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"]
|
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|