usage-cli 0.34.9__tar.gz → 0.34.10__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.
- {usage_cli-0.34.9/usage_cli.egg-info → usage_cli-0.34.10}/PKG-INFO +1 -1
- {usage_cli-0.34.9 → usage_cli-0.34.10}/adapters/rate_limits.py +1 -1
- {usage_cli-0.34.9 → usage_cli-0.34.10}/installer/setup_hook.py +1 -1
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/claude_usage.py +15 -13
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/app.py +2 -3
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/state.py +2 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/title.py +63 -8
- {usage_cli-0.34.9 → usage_cli-0.34.10}/pyproject.toml +1 -1
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_desktop_quota_review.py +1 -1
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_menubar.py +135 -1
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_usage_client.py +25 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_usage_statusline.py +107 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10/usage_cli.egg-info}/PKG-INFO +1 -1
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_hooks/usage_statusline.py +54 -6
- {usage_cli-0.34.9 → usage_cli-0.34.10}/LICENSE +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/README.md +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/THIRD_PARTY_NOTICES.md +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/adapters/__init__.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/adapters/agy.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/adapters/claude.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/adapters/codex.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/adapters/grok.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/adapters/muse.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/adapters/registry.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/adapters/types.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/analyzer/__init__.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/analyzer/aggregator.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/analyzer/blocks.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/analyzer/diagnoser.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/analyzer/diagnosis_snapshot.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/analyzer/dispatch_ledger.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/analyzer/history_persist.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/analyzer/insights.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/analyzer/ledger_backfill.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/analyzer/persona_loader.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/analyzer/reporter.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/analyzer/subscription.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/analyzer/usage_snapshot.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/i18n.json +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/installer/__init__.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/installer/claude_pane.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/installer/codex_hook_trust.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/installer/login_item.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/installer/session_hooks.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/installer/statusline_settings.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/__init__.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/_shared_cache_file.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/agy_disk_cache.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/agy_loader.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/agy_quota_probe.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/cache_quarantine.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/chromium_cache.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/claude_desktop.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/claude_paths.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/codex_disk_cache.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/codex_events.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/codex_fork_replay.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/codex_loader.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/codex_paths.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/disk_cache_common.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/disk_cache_lifecycle.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/grok_loader.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/grok_quota_probe.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/history_disk_cache.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/history_loader.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/jsonl_limits.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/jsonl_utils.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/muse_loader.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/loaders/muse_quota_probe.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/main.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/__init__.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/actions.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/agy.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/chrome.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/fsevents_watch.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/grok.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/manual_refresh.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/menu.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/muse.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/new_badge.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/notify.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/popover.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/prefs.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/refresh.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/update.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/menubar/whats_new.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/panels/__init__.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/panels/base.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/panels/catalog.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/panels/dynamic_height.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/panels/panel_scale.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/panels/panel_window.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/panels/panel_window_state.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/panels/payload.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/panels/web_panel.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/panels/window_drag.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/quota/__init__.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/quota/agy_window_keeper.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/quota/burn_rate.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/quota/codex_window_keeper.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/quota/keeper_outcome.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/quota/notifications.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/quota/quota_snapshot.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/quota/status_payload.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/quota/usage_rate.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/quota/window_keeper.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/setup.cfg +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/setup_app.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_adapters_claude.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_adapters_codex.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_adapters_rate_limits.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_aggregator.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_agy_adapter.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_agy_disk_cache.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_agy_loader.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_agy_quota_probe.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_agy_window_keeper.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_analyzer_blocks.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_analyzer_pipeline.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_app_version.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_burn_rate.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_cache_quarantine.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_check_upstream.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_chromium_cache.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_claude_desktop.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_claude_pane.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_claude_paths.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_codex_consistency.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_codex_hook_trust.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_codex_loader.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_codex_window_keeper.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_compact_cards.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_desktop_fresh_cache.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_desktop_quota_display.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_diagnoser.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_disk_cache_shards.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_dispatch_ledger.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_doctor.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_dynamic_height.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_fsevents_watch.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_grok_loader.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_grok_quota_probe.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_history_loader.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_history_persist.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_hook_scripts_are_standalone.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_html_report_snapshot.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_i18n_format_fallback.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_i18n_key_parity.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_i18n_packaged_path.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_insights.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_jsonl_utils.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_keeper_menu.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_keeper_outcome.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_ledger_backfill.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_login_item.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_main.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_manual_refresh.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_menubar_agy.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_menubar_grok.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_menubar_muse.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_menubar_notify.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_menubar_prefs.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_menubar_state.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_menubar_update.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_muse_coverage.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_muse_loader.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_muse_quota_probe.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_muse_review_regressions.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_new_badge.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_packaged_resources.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_packaging.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_panel_footer_layout.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_panel_scale.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_panel_window_state.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_panels.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_panels_lazy_import.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_persona_loader.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_pricing.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_project_resolver.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_quota_aware.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_quota_label.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_report_cold_read.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_report_daily_chart.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_report_filter_js.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_report_share_mask.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_reporter_build_data.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_service_status.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_session_resume.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_setup_app.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_setup_hook.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_setup_hook_coexistence.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_setup_hook_resume.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_setup_hook_terse.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_subprocess_utils.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_subscription.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_taskbar_menu.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_taskbar_obstacles.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_time_utils.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_tui.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_tui_sprite.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_ui_tables.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_update_checker.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_update_gate.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_update_release_notes.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_usage_cli.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_usage_diagnosis_snapshot.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_usage_dir_sweeper.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_usage_lang.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_usage_logging.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_usage_notifications.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_usage_rate.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_usage_snapshot.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_usage_snapshot_replay.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_usage_statusline_agy.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_usage_statusline_forwarder.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_usage_statusline_grok.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_usage_terse_mode.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_usage_terse_reminder.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_web_panel_payload.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_whats_new.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_wheel_packaging.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_win_login_item.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_window_keeper.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_windows_manifest.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tests/test_wintray.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tui/__init__.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tui/app.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/tui/sprite.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/ui/__init__.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/ui/html_report.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/ui/report_charts.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/ui/report_daily_chart.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/ui/report_filter.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/ui/report_ledger.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/ui/report_scripts.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/ui/report_styles.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/ui/tables.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/updates/__init__.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/updates/checker.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/updates/gate.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/updates/release_notes.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/updates/whats_new.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_app/__init__.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_app/cli.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_app/doctor.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_cli.egg-info/SOURCES.txt +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_cli.egg-info/dependency_links.txt +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_cli.egg-info/entry_points.txt +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_cli.egg-info/requires.txt +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_cli.egg-info/top_level.txt +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_common/__init__.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_common/app_version.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_common/i18n.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_common/prefs.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_common/pricing.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_common/project_resolver.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_common/service_status.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_common/subprocess_utils.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_common/time_utils.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_common/usage_dir_sweeper.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_common/usage_lang.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_common/usage_logging.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_hooks/__init__.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_hooks/usage_quota_aware.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_hooks/usage_session_resume.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_hooks/usage_statusline_agy.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_hooks/usage_statusline_forwarder.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_hooks/usage_statusline_grok.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_hooks/usage_terse_mode.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/usage_hooks/usage_terse_reminder.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/wintray/__init__.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/wintray/app.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/wintray/login_item.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/wintray/menu.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/wintray/prefs.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/wintray/quota_label.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/wintray/taskbar_menu.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/wintray/taskbar_obstacles.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/wintray/taskbar_overlay.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/wintray/watch.py +0 -0
- {usage_cli-0.34.9 → usage_cli-0.34.10}/wintray/whats_new.py +0 -0
|
@@ -44,7 +44,7 @@ def _read_status() -> dict[str, Any] | None:
|
|
|
44
44
|
|
|
45
45
|
def load_rate_limits() -> RateLimits | None:
|
|
46
46
|
data = _read_status()
|
|
47
|
-
if data is None or not usage_client.
|
|
47
|
+
if data is None or not usage_client._has_rate_limits(data):
|
|
48
48
|
snapshot = usage_client.read_quota_fallback()
|
|
49
49
|
if snapshot is not None:
|
|
50
50
|
return _snapshot_rate_limits(snapshot)
|
|
@@ -86,7 +86,7 @@ PREV_SL_KEY = "previousStatusLine"
|
|
|
86
86
|
# Where the status-line switch parks the live statusLine while it is off, so it
|
|
87
87
|
# never overwrites the pre-install backup under PREV_SL_KEY.
|
|
88
88
|
DISABLED_SL_KEY = "disabledStatusLine"
|
|
89
|
-
HOOK_VERSION = "1.
|
|
89
|
+
HOOK_VERSION = "1.16"
|
|
90
90
|
FORWARDER_VERSION = "1.1"
|
|
91
91
|
# Antigravity's Go runner hands its status-line command to cmd.exe unquoted, so
|
|
92
92
|
# every character cmd.exe treats specially has to be kept out of the path.
|
|
@@ -293,17 +293,19 @@ def _hook_broken_message(now: float, polled_at: float) -> str | None:
|
|
|
293
293
|
return HOOK_BROKEN_NOT_INSTALLED
|
|
294
294
|
|
|
295
295
|
|
|
296
|
-
def
|
|
296
|
+
def _has_rate_limits(data: dict[str, Any]) -> bool:
|
|
297
|
+
"""True when every window the hook sent is valid and at least one was sent.
|
|
298
|
+
|
|
299
|
+
Plans without a 5-hour limit never send five_hour, so requiring both windows
|
|
300
|
+
would leave them on the cache or the loading state forever. A window that is
|
|
301
|
+
present but unreadable still falls back to the cache.
|
|
302
|
+
"""
|
|
297
303
|
rl = data.get("rate_limits")
|
|
298
304
|
if not isinstance(rl, dict):
|
|
299
305
|
return False
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
return False
|
|
304
|
-
return (
|
|
305
|
-
_as_finite_float(five.get("used_percentage")) is not None
|
|
306
|
-
and _as_finite_float(seven.get("used_percentage")) is not None
|
|
306
|
+
windows = [w for w in (rl.get("five_hour"), rl.get("seven_day")) if isinstance(w, dict)]
|
|
307
|
+
return bool(windows) and all(
|
|
308
|
+
_as_finite_float(w.get("used_percentage")) is not None for w in windows
|
|
307
309
|
)
|
|
308
310
|
|
|
309
311
|
|
|
@@ -440,11 +442,11 @@ class ClaudeUsageClient:
|
|
|
440
442
|
|
|
441
443
|
# ``.claude.json`` is Claude Code's cache, not a competing live source.
|
|
442
444
|
# In particular its fetchedAtMs can be newer than the hook timestamp while
|
|
443
|
-
# still describing a different/expired session. A
|
|
444
|
-
#
|
|
445
|
-
# not provided
|
|
446
|
-
# it never replaces a
|
|
447
|
-
if not
|
|
445
|
+
# still describing a different/expired session. A statusLine payload
|
|
446
|
+
# with quota must therefore always win; use the cache only when the hook
|
|
447
|
+
# has not provided any quota window yet. Desktop history is also a fallback;
|
|
448
|
+
# it never replaces a hook payload or supplies invented resets.
|
|
449
|
+
if not _has_rate_limits(data):
|
|
448
450
|
fallback_snapshot = read_quota_fallback(self._read_claude_json_snapshot_cached)
|
|
449
451
|
if fallback_snapshot is not None:
|
|
450
452
|
return self._success_outcome(fallback_snapshot)
|
|
@@ -189,7 +189,6 @@ __all__ = [
|
|
|
189
189
|
]
|
|
190
190
|
|
|
191
191
|
UPDATE_ALERT_BODY_LIMIT = 2000
|
|
192
|
-
SLOW_POLL_INTERVAL_S = 300.0
|
|
193
192
|
|
|
194
193
|
logger = logging.getLogger(__name__)
|
|
195
194
|
|
|
@@ -317,7 +316,7 @@ class AppDelegate(NSObject):
|
|
|
317
316
|
|
|
318
317
|
self._request_notification_authorization()
|
|
319
318
|
self._refresh()
|
|
320
|
-
self._reschedule_poll_timer(
|
|
319
|
+
self._reschedule_poll_timer(menubar_title.menubar_poll_interval(self, self.latest_state))
|
|
321
320
|
self._fs_stream = setup_fsevents(self)
|
|
322
321
|
self._history_source_tracker.set_incremental_enabled(self._fs_stream is not None)
|
|
323
322
|
warm_up_pricing(self._refresh_after_pricing_warm_up)
|
|
@@ -378,7 +377,7 @@ class AppDelegate(NSObject):
|
|
|
378
377
|
|
|
379
378
|
def _panel_window_did_hide(self) -> None:
|
|
380
379
|
self.popover_controller.evictInactivePanelViews()
|
|
381
|
-
self._reschedule_poll_timer(
|
|
380
|
+
self._reschedule_poll_timer(menubar_title.menubar_poll_interval(self, self.latest_state))
|
|
382
381
|
|
|
383
382
|
def windowDidMove_(self, notification: Any) -> None:
|
|
384
383
|
if notification.object() is self.popover and self._panel_window_is_visible():
|
|
@@ -106,6 +106,7 @@ class QuotaRowState:
|
|
|
106
106
|
available: bool = True
|
|
107
107
|
reset_text_compact: str = ""
|
|
108
108
|
reset_done: bool = False
|
|
109
|
+
resets_at: float | None = None
|
|
109
110
|
|
|
110
111
|
# Keep percent unchanged for quota notifications; renderers use display_percent.
|
|
111
112
|
|
|
@@ -1083,6 +1084,7 @@ def _quota_row(
|
|
|
1083
1084
|
warning=warning,
|
|
1084
1085
|
available=True,
|
|
1085
1086
|
reset_done=time_to_reset <= 0,
|
|
1087
|
+
resets_at=resets_at,
|
|
1086
1088
|
)
|
|
1087
1089
|
if warning_seconds is not None:
|
|
1088
1090
|
row.reset_text_compact = _t(
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import math
|
|
12
|
+
import time
|
|
11
13
|
from collections.abc import Callable
|
|
12
14
|
from typing import Any, Protocol
|
|
13
15
|
|
|
@@ -27,7 +29,7 @@ from menubar.chrome import (
|
|
|
27
29
|
_menubar_icon_attachment_string,
|
|
28
30
|
_muse_menubar_icon,
|
|
29
31
|
)
|
|
30
|
-
from menubar.state import PopoverState, _format_percent
|
|
32
|
+
from menubar.state import PopoverState, QuotaRowState, _format_percent
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
class _TitleApp(Protocol):
|
|
@@ -36,6 +38,11 @@ class _TitleApp(Protocol):
|
|
|
36
38
|
_last_button_title_key: tuple[str] | None
|
|
37
39
|
_last_plain_title_key: tuple[str] | None
|
|
38
40
|
status_item: Any
|
|
41
|
+
interval: float
|
|
42
|
+
|
|
43
|
+
def _reschedule_poll_timer(self, interval: float) -> None: ...
|
|
44
|
+
|
|
45
|
+
def _panel_window_is_visible(self) -> bool: ...
|
|
39
46
|
|
|
40
47
|
|
|
41
48
|
_MENUBAR_FONT: Any = None
|
|
@@ -65,10 +72,43 @@ def _menubar_text_string(app: _TitleApp, text: str) -> Any:
|
|
|
65
72
|
|
|
66
73
|
# A wider strip slides behind the notch; the panel still lists every source.
|
|
67
74
|
_MENUBAR_MAX_ITEMS = 3
|
|
75
|
+
SLOW_POLL_INTERVAL_S = 300.0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _format_countdown(seconds: float) -> str:
|
|
79
|
+
minutes = math.ceil(seconds / 60)
|
|
80
|
+
if minutes >= 1440:
|
|
81
|
+
return f"{minutes // 1440}d{minutes % 1440 // 60}h"
|
|
82
|
+
if minutes >= 60:
|
|
83
|
+
return f"{minutes // 60}h{minutes % 60}m"
|
|
84
|
+
return f"{minutes}m"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _quota_countdown(rows: tuple[QuotaRowState, ...], now: float) -> str | None:
|
|
88
|
+
full = [row for row in rows if row.display_percent is not None and row.display_percent >= 100]
|
|
89
|
+
if not full:
|
|
90
|
+
return None
|
|
91
|
+
resets = []
|
|
92
|
+
for row in full:
|
|
93
|
+
if row.resets_at is None or row.resets_at <= now:
|
|
94
|
+
return None
|
|
95
|
+
resets.append(row.resets_at)
|
|
96
|
+
return _format_countdown(max(resets) - now)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def menubar_poll_interval(app: _TitleApp, state: PopoverState) -> float:
|
|
100
|
+
if app._panel_window_is_visible() or any(
|
|
101
|
+
text != "--" and not text.endswith("%") for _icon, text in _menubar_items(app, state)
|
|
102
|
+
):
|
|
103
|
+
return app.interval
|
|
104
|
+
return max(app.interval, SLOW_POLL_INTERVAL_S)
|
|
68
105
|
|
|
69
106
|
|
|
70
|
-
def _menubar_items(
|
|
71
|
-
|
|
107
|
+
def _menubar_items(
|
|
108
|
+
app: _TitleApp, state: PopoverState, now: float | None = None
|
|
109
|
+
) -> list[tuple[Callable[[], Any], str]]:
|
|
110
|
+
"""Visible icon/text pairs, keeping the highest-usage sources in order."""
|
|
111
|
+
now = time.time() if now is None else now
|
|
72
112
|
items: list[tuple[Callable[[], Any], float | None]] = []
|
|
73
113
|
if not state.hide_claude:
|
|
74
114
|
items.append((_claude_menubar_icon, state.claude_session.display_percent))
|
|
@@ -85,15 +125,29 @@ def _menubar_items(app: _TitleApp, state: PopoverState) -> list[tuple[Callable[[
|
|
|
85
125
|
):
|
|
86
126
|
if not hidden and percent is not None:
|
|
87
127
|
items.append((icon, percent))
|
|
128
|
+
rows_by_icon = {
|
|
129
|
+
_claude_menubar_icon: (state.claude_session, state.claude_weekly),
|
|
130
|
+
_codex_menubar_icon: (state.codex_session, state.codex_weekly),
|
|
131
|
+
_agy_menubar_icon: (state.agy_session, state.agy_weekly),
|
|
132
|
+
_grok_menubar_icon: (state.grok_weekly,),
|
|
133
|
+
_muse_menubar_icon: (state.muse_session, state.muse_weekly),
|
|
134
|
+
}
|
|
135
|
+
countdowns = [_quota_countdown(rows_by_icon[icon], now) for icon, _percent in items]
|
|
88
136
|
if len(items) > _MENUBAR_MAX_ITEMS:
|
|
89
137
|
# Stable sort, so ties keep the fixed order; an unknown "--" ranks last.
|
|
90
|
-
ranks = [
|
|
138
|
+
ranks = [
|
|
139
|
+
100.0 if countdowns[i] is not None else -1.0 if percent is None else percent
|
|
140
|
+
for i, (_icon, percent) in enumerate(items)
|
|
141
|
+
]
|
|
91
142
|
ranked = sorted(range(len(items)), key=ranks.__getitem__, reverse=True)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
143
|
+
selected = sorted(ranked[:_MENUBAR_MAX_ITEMS])
|
|
144
|
+
else:
|
|
145
|
+
selected = list(range(len(items)))
|
|
146
|
+
rendered = [
|
|
147
|
+
(icon, countdown or ("--" if percent is None else f"{_format_percent(percent)}%"))
|
|
148
|
+
for (icon, percent), countdown in zip(items, countdowns, strict=True)
|
|
96
149
|
]
|
|
150
|
+
return [rendered[i] for i in selected]
|
|
97
151
|
|
|
98
152
|
|
|
99
153
|
def _menubar_attributed_title(app: _TitleApp, state: PopoverState) -> Any:
|
|
@@ -110,6 +164,7 @@ def _menubar_attributed_title(app: _TitleApp, state: PopoverState) -> Any:
|
|
|
110
164
|
|
|
111
165
|
|
|
112
166
|
def _set_button_title(app: _TitleApp, state: PopoverState) -> None:
|
|
167
|
+
app._reschedule_poll_timer(menubar_poll_interval(app, state))
|
|
113
168
|
title = _compose_title(app, state)
|
|
114
169
|
title_key = (title,)
|
|
115
170
|
if app._last_button_title_key == title_key:
|
|
@@ -145,7 +145,7 @@ def test_complete_hook_never_reads_desktop_even_on_repeated_polls(
|
|
|
145
145
|
assert cli is not None and (cli.five_hour_pct, cli.seven_day_pct) == (12, 13)
|
|
146
146
|
|
|
147
147
|
|
|
148
|
-
@pytest.mark.parametrize("hook", [None, {}, {"five_hour": {"used_percentage":
|
|
148
|
+
@pytest.mark.parametrize("hook", [None, {}, {"five_hour": {"used_percentage": None}}])
|
|
149
149
|
def test_gui_and_cli_use_code_cache_before_desktop(
|
|
150
150
|
sources: Path, monkeypatch: pytest.MonkeyPatch, hook: dict[str, Any] | None
|
|
151
151
|
) -> None:
|
|
@@ -2363,9 +2363,13 @@ def test_set_button_title_skips_unchanged_visible_state(
|
|
|
2363
2363
|
delegate.codex_5h_pct = 12
|
|
2364
2364
|
monkeypatch.setattr(menubar_title, "_menubar_attributed_title", lambda app, current: object())
|
|
2365
2365
|
|
|
2366
|
+
intervals: list[float] = []
|
|
2367
|
+
monkeypatch.setattr(delegate, "_panel_window_is_visible", lambda: False)
|
|
2368
|
+
monkeypatch.setattr(delegate, "_reschedule_poll_timer", intervals.append)
|
|
2366
2369
|
menubar_title._set_button_title(delegate, state)
|
|
2367
2370
|
menubar_title._set_button_title(delegate, state)
|
|
2368
2371
|
|
|
2372
|
+
assert intervals == [300.0, 300.0]
|
|
2369
2373
|
assert len(button.titles) == 1
|
|
2370
2374
|
assert len(button.attributed_titles) == 1
|
|
2371
2375
|
|
|
@@ -3312,7 +3316,12 @@ def test_panel_window_hide_prunes_cache_and_keeps_slow_poll() -> None:
|
|
|
3312
3316
|
controller = SimpleNamespace(evictInactivePanelViews=Mock())
|
|
3313
3317
|
reschedule = Mock()
|
|
3314
3318
|
delegate = SimpleNamespace(
|
|
3315
|
-
popover_controller=controller,
|
|
3319
|
+
popover_controller=controller,
|
|
3320
|
+
interval=60,
|
|
3321
|
+
_reschedule_poll_timer=reschedule,
|
|
3322
|
+
_panel_window_is_visible=lambda: False,
|
|
3323
|
+
latest_state=menubar._empty_state(),
|
|
3324
|
+
codex_5h_pct=None,
|
|
3316
3325
|
)
|
|
3317
3326
|
menubar.AppDelegate._panel_window_did_hide(cast(Any, delegate))
|
|
3318
3327
|
controller.evictInactivePanelViews.assert_called_once_with()
|
|
@@ -3419,3 +3428,128 @@ def test_refresh_preserves_muse_on_success_and_fetch_error(
|
|
|
3419
3428
|
assert "OSError" in result.status_text
|
|
3420
3429
|
else:
|
|
3421
3430
|
assert result.status_text == _t("en", "status_text", value=_t("en", "status_no_data"))
|
|
3431
|
+
|
|
3432
|
+
|
|
3433
|
+
@pytest.mark.parametrize(
|
|
3434
|
+
("seconds", "expected"),
|
|
3435
|
+
[
|
|
3436
|
+
(0, "0m"),
|
|
3437
|
+
(59, "1m"),
|
|
3438
|
+
(60, "1m"),
|
|
3439
|
+
(3599, "1h0m"),
|
|
3440
|
+
(3600, "1h0m"),
|
|
3441
|
+
(10500, "2h55m"),
|
|
3442
|
+
(86400, "1d0h"),
|
|
3443
|
+
(273000, "3d3h"),
|
|
3444
|
+
(274200, "3d4h"),
|
|
3445
|
+
],
|
|
3446
|
+
)
|
|
3447
|
+
def test_menubar_countdown_format(seconds: float, expected: str) -> None:
|
|
3448
|
+
assert menubar_title._format_countdown(seconds) == expected
|
|
3449
|
+
|
|
3450
|
+
|
|
3451
|
+
@pytest.mark.parametrize("tool", ["claude", "codex", "agy", "grok", "muse"])
|
|
3452
|
+
@pytest.mark.parametrize(
|
|
3453
|
+
("session_pct", "session_reset", "weekly_pct", "weekly_reset", "expected"),
|
|
3454
|
+
[
|
|
3455
|
+
(100, 10500, 30, None, "2h55m"),
|
|
3456
|
+
(30, None, 100, 274200, "3d4h"),
|
|
3457
|
+
(100, 10500, 100, 274200, "3d4h"),
|
|
3458
|
+
(100, None, 30, None, "100%"),
|
|
3459
|
+
(100, 0, 30, None, "100%"),
|
|
3460
|
+
(100, -60, 30, None, "100%"),
|
|
3461
|
+
(100, 10500, 100, None, "100%"),
|
|
3462
|
+
(100, 10500, 100, 0, "100%"),
|
|
3463
|
+
(30, None, 99, 10500, "30%"),
|
|
3464
|
+
],
|
|
3465
|
+
)
|
|
3466
|
+
def test_menubar_tool_countdown(
|
|
3467
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
3468
|
+
tool: str,
|
|
3469
|
+
session_pct: float,
|
|
3470
|
+
session_reset: float | None,
|
|
3471
|
+
weekly_pct: float,
|
|
3472
|
+
weekly_reset: float | None,
|
|
3473
|
+
expected: str,
|
|
3474
|
+
) -> None:
|
|
3475
|
+
monkeypatch.setattr(menubar, "_load_preferences", lambda: {})
|
|
3476
|
+
state = menubar._empty_state()
|
|
3477
|
+
for name in ("claude", "codex", "agy", "grok", "muse"):
|
|
3478
|
+
setattr(state, f"hide_{name}", name != tool)
|
|
3479
|
+
now = 1000000.0
|
|
3480
|
+
if tool == "grok":
|
|
3481
|
+
# Grok has only a weekly row; exercise the session cases on that row.
|
|
3482
|
+
rows = [(state.grok_weekly, session_pct, session_reset)]
|
|
3483
|
+
expected = "2h55m" if session_pct >= 100 and session_reset == 10500 else f"{session_pct}%"
|
|
3484
|
+
else:
|
|
3485
|
+
rows = [
|
|
3486
|
+
(getattr(state, f"{tool}_session"), session_pct, session_reset),
|
|
3487
|
+
(getattr(state, f"{tool}_weekly"), weekly_pct, weekly_reset),
|
|
3488
|
+
]
|
|
3489
|
+
for row, percent, reset in rows:
|
|
3490
|
+
row.percent = percent
|
|
3491
|
+
row.reset_done = False
|
|
3492
|
+
row.resets_at = None if reset is None else now + reset
|
|
3493
|
+
app = cast(Any, SimpleNamespace(codex_5h_pct=session_pct))
|
|
3494
|
+
assert [text for _icon, text in menubar_title._menubar_items(app, state, now)] == [expected]
|
|
3495
|
+
|
|
3496
|
+
|
|
3497
|
+
@pytest.mark.parametrize(
|
|
3498
|
+
("visible", "reset", "interval", "expected"),
|
|
3499
|
+
[
|
|
3500
|
+
(False, 10500, 60, 60),
|
|
3501
|
+
(False, None, 60, 300),
|
|
3502
|
+
(False, 0, 60, 300),
|
|
3503
|
+
(True, None, 60, 60),
|
|
3504
|
+
(False, None, 600, 600),
|
|
3505
|
+
],
|
|
3506
|
+
)
|
|
3507
|
+
def test_menubar_poll_interval(
|
|
3508
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
3509
|
+
visible: bool,
|
|
3510
|
+
reset: float | None,
|
|
3511
|
+
interval: float,
|
|
3512
|
+
expected: float,
|
|
3513
|
+
) -> None:
|
|
3514
|
+
monkeypatch.setattr(menubar, "_load_preferences", lambda: {})
|
|
3515
|
+
monkeypatch.setattr(time, "time", lambda: 1000000.0)
|
|
3516
|
+
state = menubar._empty_state()
|
|
3517
|
+
state.claude_session.percent = 100
|
|
3518
|
+
state.claude_session.resets_at = None if reset is None else 1000000.0 + reset
|
|
3519
|
+
app = cast(
|
|
3520
|
+
Any,
|
|
3521
|
+
SimpleNamespace(
|
|
3522
|
+
codex_5h_pct=None,
|
|
3523
|
+
interval=interval,
|
|
3524
|
+
_panel_window_is_visible=lambda: visible,
|
|
3525
|
+
),
|
|
3526
|
+
)
|
|
3527
|
+
assert menubar_title.menubar_poll_interval(app, state) == expected
|
|
3528
|
+
state.hide_claude = True
|
|
3529
|
+
if not visible:
|
|
3530
|
+
assert menubar_title.menubar_poll_interval(app, state) == max(interval, 300)
|
|
3531
|
+
|
|
3532
|
+
|
|
3533
|
+
def test_menubar_countdown_ranks_as_full_and_preserves_codex_path(
|
|
3534
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
3535
|
+
) -> None:
|
|
3536
|
+
monkeypatch.setattr(menubar, "_load_preferences", lambda: {})
|
|
3537
|
+
state = menubar._empty_state()
|
|
3538
|
+
state.claude_session.percent = 30
|
|
3539
|
+
state.claude_weekly.percent = 100
|
|
3540
|
+
state.claude_weekly.resets_at = 1010500.0
|
|
3541
|
+
state.codex_session.percent = 80
|
|
3542
|
+
state.agy_session.percent = 60
|
|
3543
|
+
state.muse_session.percent = 50
|
|
3544
|
+
state.hide_agy = state.hide_muse = False
|
|
3545
|
+
app = cast(Any, SimpleNamespace(codex_5h_pct=40))
|
|
3546
|
+
assert [text for _icon, text in menubar_title._menubar_items(app, state, 1000000)] == [
|
|
3547
|
+
"2h55m",
|
|
3548
|
+
"60%",
|
|
3549
|
+
"50%",
|
|
3550
|
+
]
|
|
3551
|
+
state.hide_agy = state.hide_muse = True
|
|
3552
|
+
assert [text for _icon, text in menubar_title._menubar_items(app, state, 1000000)] == [
|
|
3553
|
+
"2h55m",
|
|
3554
|
+
"40%",
|
|
3555
|
+
]
|
|
@@ -457,6 +457,31 @@ def test_fetch_once_returns_awaiting_rate_limits_when_status_has_no_limits(
|
|
|
457
457
|
assert outcome.message == "awaiting_rate_limits"
|
|
458
458
|
|
|
459
459
|
|
|
460
|
+
def test_fetch_once_uses_status_with_only_weekly_window(
|
|
461
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
462
|
+
) -> None:
|
|
463
|
+
# Issue #273: plans without a 5-hour limit only send seven_day.
|
|
464
|
+
status_path = tmp_path / "usage-status.json"
|
|
465
|
+
monkeypatch.setattr(usage_client, "STATUS_FILE", str(status_path))
|
|
466
|
+
monkeypatch.setattr(usage_client, "LEGACY_STATUS_FILE", str(tmp_path / f"{LEGACY_NAME}.json"))
|
|
467
|
+
monkeypatch.setattr(usage_client, "TT_STATUS_FILE", str(tmp_path / "tt-status.json"))
|
|
468
|
+
monkeypatch.setattr(
|
|
469
|
+
usage_client, "read_quota_fallback", lambda *_: pytest.fail("fallback must not run")
|
|
470
|
+
)
|
|
471
|
+
status_path.write_text(
|
|
472
|
+
json.dumps(
|
|
473
|
+
{"rate_limits": {"seven_day": {"used_percentage": 46, "resets_at": 9_999_999_999}}}
|
|
474
|
+
),
|
|
475
|
+
encoding="utf-8",
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
outcome = asyncio.run(usage_client.ClaudeUsageClient(mock=False).fetch_once())
|
|
479
|
+
|
|
480
|
+
assert outcome.snapshot is not None
|
|
481
|
+
assert outcome.snapshot.current_percent is None
|
|
482
|
+
assert outcome.snapshot.weekly_percent == 46
|
|
483
|
+
|
|
484
|
+
|
|
460
485
|
def test_fetch_once_reuses_parsed_data_and_rebuilds_when_status_mtime_is_unchanged(
|
|
461
486
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
462
487
|
) -> None:
|
|
@@ -408,6 +408,64 @@ def test_save_overwrites_existing_rate_limits_when_new_data_is_complete(
|
|
|
408
408
|
assert data["_received_at_ts"] == now.timestamp()
|
|
409
409
|
|
|
410
410
|
|
|
411
|
+
@pytest.mark.parametrize(
|
|
412
|
+
("cached_five_reset", "expected_five"),
|
|
413
|
+
[
|
|
414
|
+
# Issue #273: a plan without a 5-hour limit stops sending five_hour.
|
|
415
|
+
# The expired cached window is dropped and seven_day is not frozen.
|
|
416
|
+
(1_000, None),
|
|
417
|
+
# A cached window whose reset is still ahead fills the gap.
|
|
418
|
+
(9_999_999_999, {"used_percentage": 11, "resets_at": 9_999_999_999}),
|
|
419
|
+
],
|
|
420
|
+
)
|
|
421
|
+
def test_save_merges_rate_limit_windows_one_by_one(
|
|
422
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
423
|
+
tmp_path: Path,
|
|
424
|
+
cached_five_reset: int,
|
|
425
|
+
expected_five: dict[str, Any] | None,
|
|
426
|
+
) -> None:
|
|
427
|
+
status_file = tmp_path / "usage-status.json"
|
|
428
|
+
status_file.write_text(
|
|
429
|
+
json.dumps(
|
|
430
|
+
{
|
|
431
|
+
"rate_limits": {
|
|
432
|
+
"five_hour": {"used_percentage": 11, "resets_at": cached_five_reset},
|
|
433
|
+
"seven_day": {"used_percentage": 34, "resets_at": 9_999_999_999},
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
),
|
|
437
|
+
encoding="utf-8",
|
|
438
|
+
)
|
|
439
|
+
monkeypatch.setattr(usage_statusline, "STATUS_FILE", str(status_file))
|
|
440
|
+
fresh_seven = {"used_percentage": 46, "resets_at": 1_793_660_400}
|
|
441
|
+
|
|
442
|
+
usage_statusline.save(
|
|
443
|
+
{"rate_limits": {"seven_day": fresh_seven}},
|
|
444
|
+
datetime(2026, 11, 1, 12, 0, tzinfo=UTC),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
rate_limits = json.loads(status_file.read_text(encoding="utf-8"))["rate_limits"]
|
|
448
|
+
assert rate_limits["seven_day"] == fresh_seven
|
|
449
|
+
assert rate_limits.get("five_hour") == expected_five
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_save_keeps_expired_cached_windows_when_payload_has_no_quota(
|
|
453
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
454
|
+
tmp_path: Path,
|
|
455
|
+
) -> None:
|
|
456
|
+
cached = {
|
|
457
|
+
"five_hour": {"used_percentage": 11, "resets_at": 1_000},
|
|
458
|
+
"seven_day": {"used_percentage": 34, "resets_at": 1_000},
|
|
459
|
+
}
|
|
460
|
+
status_file = tmp_path / "usage-status.json"
|
|
461
|
+
status_file.write_text(json.dumps({"rate_limits": cached}), encoding="utf-8")
|
|
462
|
+
monkeypatch.setattr(usage_statusline, "STATUS_FILE", str(status_file))
|
|
463
|
+
|
|
464
|
+
usage_statusline.save({"model": {"display_name": "new"}}, datetime(2026, 11, 1, tzinfo=UTC))
|
|
465
|
+
|
|
466
|
+
assert json.loads(status_file.read_text(encoding="utf-8"))["rate_limits"] == cached
|
|
467
|
+
|
|
468
|
+
|
|
411
469
|
def _write_prefs(path: Path, prefs: dict[str, Any]) -> None:
|
|
412
470
|
path.write_text(json.dumps(prefs), encoding="utf-8")
|
|
413
471
|
|
|
@@ -1512,3 +1570,52 @@ def test_mix_sweeps_stale_files_when_a_new_session_starts(tmp_path: Path) -> Non
|
|
|
1512
1570
|
transcript.write_bytes(_mix_record({"type": "image"}))
|
|
1513
1571
|
assert usage_statusline.read_mix({"session_id": "one", "transcript_path": str(transcript)})
|
|
1514
1572
|
assert sorted(p.name for p in mix_dir.iterdir()) == ["fresh.json", "one.json"]
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
@pytest.mark.parametrize("lang", ["zh-TW", "zh-CN", "en"])
|
|
1576
|
+
@pytest.mark.parametrize("width", [200, 16, 1])
|
|
1577
|
+
@pytest.mark.parametrize("percent", [99, 100, 101])
|
|
1578
|
+
@pytest.mark.parametrize("reset", [None, -60, 0, 10500, 274200])
|
|
1579
|
+
def test_render_quota_countdown_variants(
|
|
1580
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1581
|
+
tmp_path: Path,
|
|
1582
|
+
lang: str,
|
|
1583
|
+
width: int,
|
|
1584
|
+
percent: int,
|
|
1585
|
+
reset: int | None,
|
|
1586
|
+
) -> None:
|
|
1587
|
+
monkeypatch.setenv("TT_LANG", lang)
|
|
1588
|
+
monkeypatch.setattr(usage_statusline, "get_width", lambda: width)
|
|
1589
|
+
monkeypatch.setattr(usage_statusline, "read_mix", lambda _data: None)
|
|
1590
|
+
monkeypatch.setattr(usage_statusline, "PREFERENCES_FILE", str(tmp_path / "update.json"))
|
|
1591
|
+
monkeypatch.setattr(usage_statusline, "CONTEXT_BURN_FILE", str(tmp_path / "burn.json"))
|
|
1592
|
+
now = datetime(2026, 1, 1, 23, tzinfo=UTC)
|
|
1593
|
+
resets_at = None if reset is None else now.timestamp() + reset
|
|
1594
|
+
payload = {"rate_limits": {"five_hour": {"used_percentage": percent, "resets_at": resets_at}}}
|
|
1595
|
+
colors = usage_statusline.C
|
|
1596
|
+
prefix = f"{colors['blue']}{usage_statusline._t('five_hour')}:{colors['reset']}"
|
|
1597
|
+
suffix = ""
|
|
1598
|
+
remaining = ""
|
|
1599
|
+
clock = ""
|
|
1600
|
+
if reset is not None and reset > 0:
|
|
1601
|
+
remaining = usage_statusline.fmt_duration(reset)
|
|
1602
|
+
remaining = (
|
|
1603
|
+
usage_statusline._t("remaining_prefix") + remaining
|
|
1604
|
+
if lang in ("zh-TW", "zh-CN")
|
|
1605
|
+
else remaining + " " + usage_statusline._t("remaining_prefix")
|
|
1606
|
+
)
|
|
1607
|
+
assert resets_at is not None
|
|
1608
|
+
reset_dt = datetime.fromtimestamp(resets_at)
|
|
1609
|
+
clock = f"{reset_dt:%H:%M}"
|
|
1610
|
+
if reset_dt.date() != datetime.fromtimestamp(now.timestamp()).date():
|
|
1611
|
+
clock = f"{reset_dt.month}/{reset_dt.day} {clock}"
|
|
1612
|
+
suffix = f" {colors['dim']}{clock}({remaining}){colors['reset']}"
|
|
1613
|
+
if percent >= 100 and remaining:
|
|
1614
|
+
content = f"{clock}({remaining})" if width == 200 else remaining
|
|
1615
|
+
expected = f"{prefix}{colors['red']}{content}{colors['reset']}"
|
|
1616
|
+
else:
|
|
1617
|
+
bar = usage_statusline.progress_bar(percent, 8 if width >= 100 else 4)
|
|
1618
|
+
expected = prefix + (f"{percent}%" if width == 1 else bar)
|
|
1619
|
+
if width == 200:
|
|
1620
|
+
expected += suffix
|
|
1621
|
+
assert usage_statusline._render_core(payload, now) == expected
|
|
@@ -69,7 +69,7 @@ else:
|
|
|
69
69
|
fcntl = _fcntl
|
|
70
70
|
msvcrt = _msvcrt
|
|
71
71
|
|
|
72
|
-
__version__ = "1.
|
|
72
|
+
__version__ = "1.16"
|
|
73
73
|
|
|
74
74
|
STATUS_FILE = os.path.expanduser("~/.claude/usage-status.json")
|
|
75
75
|
LOCK_FILE = os.path.expanduser("~/.claude/usage-status.lock")
|
|
@@ -421,6 +421,46 @@ def _rate_limits_complete(rate_limits: Any) -> bool:
|
|
|
421
421
|
return five.get("used_percentage") is not None and seven.get("used_percentage") is not None
|
|
422
422
|
|
|
423
423
|
|
|
424
|
+
_RATE_WINDOWS = ("five_hour", "seven_day")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _window_used(window: Any) -> bool:
|
|
428
|
+
return isinstance(window, dict) and window.get("used_percentage") is not None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _window_unexpired(window: Any, now_ts: float) -> bool:
|
|
432
|
+
try:
|
|
433
|
+
return float(window.get("resets_at")) > now_ts
|
|
434
|
+
except (TypeError, ValueError):
|
|
435
|
+
return False
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _merge_rate_limits(incoming: Any, cached: Any, now_ts: float) -> Any:
|
|
439
|
+
"""Merge quota windows one by one; plans without a 5-hour limit omit five_hour.
|
|
440
|
+
|
|
441
|
+
A payload with no window at all (a new session before its first reply) keeps
|
|
442
|
+
the cached block; the reader zeroes expired windows. Otherwise a fresh window
|
|
443
|
+
wins, and a missing one is taken from the cache only while its reset is still
|
|
444
|
+
ahead, so a window Claude Code stopped sending disappears instead of freezing.
|
|
445
|
+
"""
|
|
446
|
+
if not isinstance(cached, dict) or not any(_window_used(cached.get(k)) for k in _RATE_WINDOWS):
|
|
447
|
+
return incoming
|
|
448
|
+
if not isinstance(incoming, dict) or not any(
|
|
449
|
+
_window_used(incoming.get(k)) for k in _RATE_WINDOWS
|
|
450
|
+
):
|
|
451
|
+
return cached
|
|
452
|
+
merged = dict(incoming)
|
|
453
|
+
for key in _RATE_WINDOWS:
|
|
454
|
+
if _window_used(merged.get(key)):
|
|
455
|
+
continue
|
|
456
|
+
old = cached.get(key)
|
|
457
|
+
if _window_used(old) and _window_unexpired(old, now_ts):
|
|
458
|
+
merged[key] = old
|
|
459
|
+
else:
|
|
460
|
+
merged.pop(key, None)
|
|
461
|
+
return merged
|
|
462
|
+
|
|
463
|
+
|
|
424
464
|
def _acquire_msvcrt_lock(lock_fd: int) -> bool:
|
|
425
465
|
"""Block (bounded) until the byte is locked; False if locking is unavailable.
|
|
426
466
|
|
|
@@ -499,9 +539,11 @@ def save(data: Dict[str, Any], now: datetime) -> None:
|
|
|
499
539
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
500
540
|
existing = None
|
|
501
541
|
if isinstance(existing, dict):
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
542
|
+
merged = _merge_rate_limits(
|
|
543
|
+
data.get("rate_limits"), existing.get("rate_limits"), now.timestamp()
|
|
544
|
+
)
|
|
545
|
+
if merged is not None:
|
|
546
|
+
data["rate_limits"] = merged
|
|
505
547
|
fd, tmp_path = tempfile.mkstemp(dir=target_dir, suffix=".tmp")
|
|
506
548
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
507
549
|
json.dump(data, f, ensure_ascii=False)
|
|
@@ -996,9 +1038,15 @@ def _render_core(data: Dict[str, Any], now: datetime) -> str:
|
|
|
996
1038
|
if reset_dt.date() != datetime.fromtimestamp(now.timestamp()).date():
|
|
997
1039
|
clock = f"{reset_dt.month}/{reset_dt.day} {clock}"
|
|
998
1040
|
if lang in ("zh-TW", "zh-CN"):
|
|
999
|
-
|
|
1041
|
+
remaining = f"{_t('remaining_prefix')}{fmt_duration(remain)}"
|
|
1000
1042
|
else:
|
|
1001
|
-
|
|
1043
|
+
remaining = f"{fmt_duration(remain)} {_t('remaining_prefix')}"
|
|
1044
|
+
if pct_float >= 100:
|
|
1045
|
+
prefix = f"{C['blue']}{label}:{C['reset']}{C['red']}"
|
|
1046
|
+
compact = f"{prefix}{remaining}{C['reset']}"
|
|
1047
|
+
rl_parts.append((f"{prefix}{clock}({remaining}){C['reset']}", compact, compact))
|
|
1048
|
+
continue
|
|
1049
|
+
reset_str = f" {clock}({remaining})"
|
|
1002
1050
|
reset_str = f" {C['dim']}{reset_str[1:]}{C['reset']}"
|
|
1003
1051
|
rl_parts.append(
|
|
1004
1052
|
(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|