usage-cli 0.31.2__tar.gz → 0.31.4__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.31.2/usage_cli.egg-info → usage_cli-0.31.4}/PKG-INFO +3 -3
- {usage_cli-0.31.2 → usage_cli-0.31.4}/README.md +2 -2
- {usage_cli-0.31.2 → usage_cli-0.31.4}/adapters/claude.py +9 -2
- {usage_cli-0.31.2 → usage_cli-0.31.4}/analyzer/reporter.py +1 -1
- {usage_cli-0.31.2 → usage_cli-0.31.4}/i18n.json +5 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/installer/session_hooks.py +40 -2
- {usage_cli-0.31.2 → usage_cli-0.31.4}/installer/setup_hook.py +1 -1
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/history_loader.py +11 -5
- {usage_cli-0.31.2 → usage_cli-0.31.4}/menubar/app.py +1 -1
- {usage_cli-0.31.2 → usage_cli-0.31.4}/menubar/notify.py +10 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/pricing.py +6 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/pyproject.toml +1 -1
- {usage_cli-0.31.2 → usage_cli-0.31.4}/setup_app.py +1 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_adapters_claude.py +52 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_history_loader.py +26 -0
- usage_cli-0.31.4/tests/test_menubar_notify.py +37 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_pricing.py +7 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_session_resume.py +160 -2
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_setup_hook_resume.py +43 -3
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_usage_statusline.py +82 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/ui/tables.py +1 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4/usage_cli.egg-info}/PKG-INFO +3 -3
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_cli.egg-info/SOURCES.txt +1 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_session_resume.py +98 -14
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_statusline.py +30 -8
- {usage_cli-0.31.2 → usage_cli-0.31.4}/LICENSE +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/adapters/__init__.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/adapters/agy.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/adapters/codex.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/adapters/grok.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/adapters/muse.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/adapters/rate_limits.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/adapters/registry.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/adapters/types.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/analyzer/__init__.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/analyzer/aggregator.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/analyzer/blocks.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/analyzer/diagnoser.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/analyzer/dispatch_ledger.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/analyzer/insights.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/analyzer/persona_loader.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/analyzer/subscription.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/analyzer/usage_snapshot.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/doctor.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/fsevents_watch.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/i18n.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/installer/__init__.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/installer/login_item.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/installer/statusline_settings.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/__init__.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/agy_disk_cache.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/agy_loader.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/agy_quota_probe.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/cache_quarantine.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/claude_paths.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/codex_disk_cache.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/codex_events.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/codex_fork_replay.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/codex_loader.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/codex_paths.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/disk_cache_common.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/disk_cache_lifecycle.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/grok_loader.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/grok_quota_probe.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/history_disk_cache.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/jsonl_limits.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/jsonl_utils.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/loaders/muse_loader.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/main.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/menubar/__init__.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/menubar/actions.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/menubar/agy.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/menubar/chrome.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/menubar/grok.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/menubar/menu.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/menubar/popover.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/menubar/prefs.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/menubar/refresh.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/menubar/state.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/menubar/title.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/menubar/update.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/panels/__init__.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/panels/base.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/panels/dynamic_height.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/panels/panel_scale.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/panels/panel_window.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/panels/panel_window_state.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/panels/payload.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/panels/web_panel.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/panels/window_drag.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/prefs.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/project_resolver.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/quota/__init__.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/quota/agy_window_keeper.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/quota/burn_rate.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/quota/codex_window_keeper.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/quota/usage_rate.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/quota/window_keeper.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/service_status.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/setup.cfg +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_adapters_codex.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_adapters_rate_limits.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_aggregator.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_agy_adapter.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_agy_disk_cache.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_agy_loader.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_agy_quota_probe.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_agy_window_keeper.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_analyzer_blocks.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_analyzer_pipeline.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_burn_rate.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_cache_quarantine.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_claude_paths.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_codex_consistency.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_codex_loader.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_codex_window_keeper.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_diagnoser.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_disk_cache_shards.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_dispatch_ledger.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_doctor.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_dynamic_height.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_fsevents_watch.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_grok_loader.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_grok_quota_probe.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_hook_scripts_are_standalone.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_html_report_snapshot.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_i18n_format_fallback.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_i18n_key_parity.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_i18n_packaged_path.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_insights.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_jsonl_utils.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_login_item.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_main.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_menubar.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_menubar_agy.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_menubar_grok.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_menubar_prefs.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_menubar_state.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_muse_loader.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_packaged_resources.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_packaging.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_panel_footer_layout.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_panel_scale.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_panel_window_state.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_panels.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_panels_lazy_import.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_persona_loader.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_project_resolver.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_report_daily_chart.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_report_filter_js.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_reporter_build_data.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_service_status.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_setup_app.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_setup_hook.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_setup_hook_coexistence.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_setup_hook_terse.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_subprocess_utils.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_subscription.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_time_utils.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_tui.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_tui_sprite.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_ui_tables.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_update_checker.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_update_gate.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_update_release_notes.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_usage_cli.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_usage_client.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_usage_diagnosis_snapshot.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_usage_dir_sweeper.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_usage_lang.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_usage_logging.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_usage_notifications.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_usage_rate.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_usage_snapshot.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_usage_snapshot_replay.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_usage_statusline_agy.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_usage_statusline_forwarder.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_usage_statusline_grok.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_usage_terse_mode.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_usage_terse_reminder.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_web_panel_payload.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_wheel_packaging.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_win_login_item.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_window_keeper.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_windows_manifest.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tests/test_wintray.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tui/__init__.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tui/app.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/tui/sprite.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/ui/__init__.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/ui/html_report.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/ui/report_charts.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/ui/report_daily_chart.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/ui/report_filter.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/ui/report_ledger.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/ui/report_scripts.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/ui/report_styles.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/updates/__init__.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/updates/checker.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/updates/gate.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/updates/release_notes.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_cli.egg-info/dependency_links.txt +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_cli.egg-info/entry_points.txt +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_cli.egg-info/requires.txt +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_cli.egg-info/top_level.txt +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_cli.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_client.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_common/__init__.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_common/subprocess_utils.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_common/time_utils.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_common/usage_dir_sweeper.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_common/usage_lang.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_common/usage_logging.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_diagnosis_snapshot.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_notifications.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_statusline_agy.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_statusline_forwarder.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_statusline_grok.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_terse_mode.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/usage_terse_reminder.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/wintray/__init__.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/wintray/app.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/wintray/login_item.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/wintray/menu.py +0 -0
- {usage_cli-0.31.2 → usage_cli-0.31.4}/wintray/watch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: usage-cli
|
|
3
|
-
Version: 0.31.
|
|
3
|
+
Version: 0.31.4
|
|
4
4
|
Summary: usage — 在 macOS menu bar 與 Windows 系統匣顯示 Claude Code、Codex、Antigravity 用量的小工具(也提供終端機 TUI)
|
|
5
5
|
License-Expression: AGPL-3.0-only
|
|
6
6
|
Requires-Python: >=3.13
|
|
@@ -67,12 +67,12 @@ It lands in your Applications folder automatically. Open it once; if macOS 15 or
|
|
|
67
67
|
- **Muse Code Spending:** Muse Code's per-request tokens and cost count toward today's cost, project totals, the HTML report, and the `usage` CLI, read from its own local session logs. Muse keeps no local quota data, so there is no Muse quota card.
|
|
68
68
|
- **Service Status Alerts:** An orange-red banner appears when Claude Code, Claude API, or Codex API has an outage or degraded performance, read from their public Statuspage.io pages — never an LLM usage API. Antigravity isn't covered; it has no public status page.
|
|
69
69
|
- **Context Nudges & Notifications:** When your context window hits 70% — or earlier when it is filling fast — the status line nudges you to `/clear` or `/compact` to prevent token waste. You can also opt-in to system notifications for quota limits and recoveries.
|
|
70
|
-
- **Prompt Cache Health:** The status line shows Claude Code's prompt cache hit rate with a countdown to expiry, so you can tell at a glance whether finishing now still reuses the cached context or lets it go cold and re-sends everything. Needs Claude Code 2.1.251 or newer; on older versions the segment simply doesn't appear.
|
|
70
|
+
- **Prompt Cache Health:** The status line shows Claude Code's prompt cache hit rate with a countdown to expiry, so you can tell at a glance whether finishing now still reuses the cached context or lets it go cold and re-sends everything. Once it has gone cold, the countdown turns into how many tokens your next message will re-cache. Needs Claude Code 2.1.251 or newer; on older versions the segment simply doesn't appear.
|
|
71
71
|
- **Hide Sections:** Only use one or two of the tools? Hide the Claude Code, Codex, Grok CLI, or Antigravity section from the menu bar and panels completely with a single click.
|
|
72
72
|
|
|
73
73
|
### Workflow Helpers
|
|
74
74
|
|
|
75
|
-
- **Progress Concierge:** Open a new Claude Code session and `usage` hands your last progress straight to the AI, including your last request, uncommitted changes, and unfinished todos. No `/resume`, no recap. Fully local, off by default.
|
|
75
|
+
- **Progress Concierge:** Open a new Claude Code session and `usage` hands your last progress straight to the AI, including your last request, uncommitted changes, and unfinished todos. No `/resume`, no recap. When you do `/resume` a conversation that sat long enough for its cache to expire, it warns you how many tokens the next message will re-send and suggests `/compact` first. Fully local, off by default.
|
|
76
76
|
- **Token Saver:** A menu-bar toggle asks Claude Code and Codex to answer more tersely and in plainer language, saving output tokens while keeping code and error messages byte-exact. A light reminder keeps long conversations from drifting back to verbose — in an A/B test on real sessions, late replies stayed ~40% shorter instead of drifting 84% longer.
|
|
77
77
|
- **Terminal Integration:** `usage status --json` hands your Claude Code and Codex quota to any tool that can run a command — Starship, tmux, or your own scripts. Reads the same local files as the menu bar, no network call. [Ready-made snippets](docs/DEVELOPMENT.md#quota-status-for-other-tools-usage-status).
|
|
78
78
|
- **Token-waste Health Check:** A daily background diagnosis scans your logs for waste, including repeated file reads, polluter directories, and noisy Bash output. If it finds issues, a one-line heads-up appears; say "show me" and the AI walks you through fixes.
|
|
@@ -47,12 +47,12 @@ It lands in your Applications folder automatically. Open it once; if macOS 15 or
|
|
|
47
47
|
- **Muse Code Spending:** Muse Code's per-request tokens and cost count toward today's cost, project totals, the HTML report, and the `usage` CLI, read from its own local session logs. Muse keeps no local quota data, so there is no Muse quota card.
|
|
48
48
|
- **Service Status Alerts:** An orange-red banner appears when Claude Code, Claude API, or Codex API has an outage or degraded performance, read from their public Statuspage.io pages — never an LLM usage API. Antigravity isn't covered; it has no public status page.
|
|
49
49
|
- **Context Nudges & Notifications:** When your context window hits 70% — or earlier when it is filling fast — the status line nudges you to `/clear` or `/compact` to prevent token waste. You can also opt-in to system notifications for quota limits and recoveries.
|
|
50
|
-
- **Prompt Cache Health:** The status line shows Claude Code's prompt cache hit rate with a countdown to expiry, so you can tell at a glance whether finishing now still reuses the cached context or lets it go cold and re-sends everything. Needs Claude Code 2.1.251 or newer; on older versions the segment simply doesn't appear.
|
|
50
|
+
- **Prompt Cache Health:** The status line shows Claude Code's prompt cache hit rate with a countdown to expiry, so you can tell at a glance whether finishing now still reuses the cached context or lets it go cold and re-sends everything. Once it has gone cold, the countdown turns into how many tokens your next message will re-cache. Needs Claude Code 2.1.251 or newer; on older versions the segment simply doesn't appear.
|
|
51
51
|
- **Hide Sections:** Only use one or two of the tools? Hide the Claude Code, Codex, Grok CLI, or Antigravity section from the menu bar and panels completely with a single click.
|
|
52
52
|
|
|
53
53
|
### Workflow Helpers
|
|
54
54
|
|
|
55
|
-
- **Progress Concierge:** Open a new Claude Code session and `usage` hands your last progress straight to the AI, including your last request, uncommitted changes, and unfinished todos. No `/resume`, no recap. Fully local, off by default.
|
|
55
|
+
- **Progress Concierge:** Open a new Claude Code session and `usage` hands your last progress straight to the AI, including your last request, uncommitted changes, and unfinished todos. No `/resume`, no recap. When you do `/resume` a conversation that sat long enough for its cache to expire, it warns you how many tokens the next message will re-send and suggests `/compact` first. Fully local, off by default.
|
|
56
56
|
- **Token Saver:** A menu-bar toggle asks Claude Code and Codex to answer more tersely and in plainer language, saving output tokens while keeping code and error messages byte-exact. A light reminder keeps long conversations from drifting back to verbose — in an A/B test on real sessions, late replies stayed ~40% shorter instead of drifting 84% longer.
|
|
57
57
|
- **Terminal Integration:** `usage status --json` hands your Claude Code and Codex quota to any tool that can run a command — Starship, tmux, or your own scripts. Reads the same local files as the menu bar, no network call. [Ready-made snippets](docs/DEVELOPMENT.md#quota-status-for-other-tools-usage-status).
|
|
58
58
|
- **Token-waste Health Check:** A daily background diagnosis scans your logs for waste, including repeated file reads, polluter directories, and noisy Bash output. If it finds issues, a one-line heads-up appears; say "show me" and the AI walks you through fixes.
|
|
@@ -137,16 +137,23 @@ def parse_jsonl(
|
|
|
137
137
|
_file_cache.move_to_end(path)
|
|
138
138
|
parsed_entries = cached[2]
|
|
139
139
|
else:
|
|
140
|
-
|
|
140
|
+
by_key: dict[str, UsageEntry] = {}
|
|
141
141
|
try:
|
|
142
142
|
for data in iter_jsonl_dicts(path):
|
|
143
143
|
if data.get("type") != "assistant":
|
|
144
144
|
continue
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
for entry in _parse_assistant_entry(data, project):
|
|
147
|
+
kept = by_key.setdefault(entry.dedup_key, entry)
|
|
148
|
+
# Subagent transcripts log one request several times while it streams,
|
|
149
|
+
# with output_tokens still growing, so keep the largest, not the first.
|
|
150
|
+
# Without ids the key cannot tell requests apart, so never merge those.
|
|
151
|
+
if entry.message_id or entry.request_id:
|
|
152
|
+
kept.output_tokens = max(kept.output_tokens, entry.output_tokens)
|
|
147
153
|
except (OSError, PermissionError, UnicodeDecodeError) as exc:
|
|
148
154
|
_debug_file_error("failed to read Claude log", path, exc)
|
|
149
155
|
return
|
|
156
|
+
parsed_entries = list(by_key.values())
|
|
150
157
|
|
|
151
158
|
if path not in _file_cache and len(_file_cache) >= _FILE_CACHE_MAXSIZE:
|
|
152
159
|
_file_cache.popitem(last=False)
|
|
@@ -43,7 +43,7 @@ _YEAR_WEEKS = 53
|
|
|
43
43
|
YEAR_CACHE_PATH = Path(os.path.expanduser("~/.usage/year_cache.json"))
|
|
44
44
|
YEAR_LEDGER_PATH = YEAR_CACHE_PATH.with_name("year_ledger.json")
|
|
45
45
|
YEAR_CACHE_TTL_SECONDS = 6 * 3600
|
|
46
|
-
_YEAR_CACHE_SCHEMA =
|
|
46
|
+
_YEAR_CACHE_SCHEMA = 5
|
|
47
47
|
_YEAR_LEDGER_SCHEMA = 1
|
|
48
48
|
_YEAR_LEDGER_TRIM_BUFFER_DAYS = 60
|
|
49
49
|
|
|
@@ -322,6 +322,7 @@
|
|
|
322
322
|
"report_rw_inject_lead": "(這是進度交接。請在這次對話第一則回覆的最前面,先說一行「🐾 已接回上次進度,繼續吧!」,然後別照唸下面的線索,而是像聰明又貼心的搭檔那樣消化它:先判斷使用者最後真正在忙的是什麼(「最近在忙的」是新到舊排列,以最前面那筆為準,別被較舊的帶偏),用一兩句溫暖具體地說他上次做到哪、完成了什麼,再直接給出你判斷最該接著做的下一步——要具體,別反問「要做什麼」。只能根據下面線索講,沒有的別編;若線索太少看不出方向,就坦白說、並列出你看到的幾條線讓他挑。接著正常回應。)\n\n",
|
|
323
323
|
"report_rw_empty": "(請在你這次對話的第一則回覆最前面,說一行「🐾 歡迎回來,這個專案目前沒有要接的進度。」,再正常回應。)",
|
|
324
324
|
"report_rw_prompt": "專案:{project}(最後活動 {when})\n最近在忙的(新→舊):{last_request}\n完成的進度:{commits}\n未完成待辦:{todos}",
|
|
325
|
+
"report_rw_resume_cost": "這個對話放了 {idle},快取已過期,下一句會重送約 {tokens} token。想省額度可以先打 /compact 壓縮再聊。",
|
|
325
326
|
"report_rw_uncommitted": "上次離開時還留著:{branch} 分支有 {count} 個檔案改了還沒提交({files})",
|
|
326
327
|
"report_rw_diagnosis_reminder": "健檢提醒:最近約 {waste_pct}% 浪費在{cause}。跟我說「修」,完整診斷在 {path}。",
|
|
327
328
|
"report_rw_diagnosis_reminder_explain": "健檢提醒:最近約 {waste_pct}% 浪費在{cause}。跟我說「看」,我會讀 {path} 的完整診斷,解釋給你聽並給具體建議。",
|
|
@@ -749,6 +750,7 @@
|
|
|
749
750
|
"report_rw_inject_lead": "(This is a resume handoff. At the very start of your first reply, lead with one line: \"🐾 Picked up where you left off — let's keep going!\", then, instead of reading the traces below aloud, digest them like a sharp, thoughtful partner: first work out what the user was actually in the middle of (\"Recently working on\" is newest-first, so trust the topmost item and don't get pulled back to older ones); in a sentence or two, warmly and concretely recap where they left off and what got done; then give the single concrete next step you would take — be specific, don't ask \"what should I do\". Use only what is below; invent nothing. If the traces are too thin to tell, say so plainly and list the threads you can see for them to pick. Then respond normally.)\n\n",
|
|
750
751
|
"report_rw_empty": "(At the very start of your first reply in this session, say one line: \"🐾 Welcome back — nothing to pick up on this project yet.\", then respond normally.)",
|
|
751
752
|
"report_rw_prompt": "Project: {project} (last active {when})\nRecently working on (newest first): {last_request}\nProgress made: {commits}\nOpen to-dos: {todos}",
|
|
753
|
+
"report_rw_resume_cost": "This conversation sat idle for {idle} and its prompt cache has expired, so your next message re-sends about {tokens} tokens. Run /compact first to use less quota.",
|
|
752
754
|
"report_rw_uncommitted": "Left uncommitted last time: {count} changed file(s) on branch {branch} ({files})",
|
|
753
755
|
"report_rw_diagnosis_reminder": "Health check: about {waste_pct}% waste came from {cause}. Say \"fix it\" and I'll read the full diagnosis at {path}.",
|
|
754
756
|
"report_rw_diagnosis_reminder_explain": "Health check: about {waste_pct}% waste came from {cause}. Say \"show me\" and I'll walk you through the full diagnosis at {path}.",
|
|
@@ -1176,6 +1178,7 @@
|
|
|
1176
1178
|
"report_rw_inject_lead": "(这是进度交接。请在这次对话第一则回复的最前面,先说一行「🐾 已接回上次进度,继续吧!」,然后别照念下面的线索,而是像聪明又贴心的搭档那样消化它:先判断用户最后真正在忙的是什么(「最近在忙的」是新到旧排列,以最前面那笔为准,别被较旧的带偏),用一两句温暖具体地说他上次做到哪、完成了什么,再直接给出你判断最该接着做的下一步——要具体,别反问「要做什么」。只能根据下面线索讲,没有的别编;若线索太少看不出方向,就坦白说、并列出你看到的几条线让他挑。接着正常回应。)\n\n",
|
|
1177
1179
|
"report_rw_empty": "(请在你这次对话的第一则回复最前面,说一行「🐾 欢迎回来,这个项目目前没有要接的进度。」,再正常回应。)",
|
|
1178
1180
|
"report_rw_prompt": "项目:{project}(最后活动 {when})\n最近在忙的(新→旧):{last_request}\n完成的进度:{commits}\n未完成待办:{todos}",
|
|
1181
|
+
"report_rw_resume_cost": "这个对话放了 {idle},缓存已过期,下一句会重新发送约 {tokens} token。想省额度可以先输入 /compact 压缩再聊。",
|
|
1179
1182
|
"report_rw_uncommitted": "上次离开时还留着:{branch} 分支有 {count} 个文件改了还没提交({files})",
|
|
1180
1183
|
"report_rw_diagnosis_reminder": "健检提醒:最近约有 {waste_pct}% 的浪费来自{cause}。跟我说「修」,完整诊断在 {path}。",
|
|
1181
1184
|
"report_rw_diagnosis_reminder_explain": "健检提醒:最近约有 {waste_pct}% 的浪费来自{cause}。跟我说「看」,我会读 {path} 的完整诊断,解释给你听并给具体建议。",
|
|
@@ -1603,6 +1606,7 @@
|
|
|
1603
1606
|
"report_rw_inject_lead": "(これは進捗の引き継ぎです。最初の返信の冒頭で、まず一行「🐾 前回の続きを引き継ぎました!そのまま続けましょう!」と述べ、下記の手がかりをそのまま読み上げるのではなく、賢く気の利いた相棒のように消化してください:まずユーザーが最後に実際に取り組んでいたことを見極め(「最近の作業」は新しい順なので、先頭の項目を信頼し、古いものに引きずられないこと)、前回どこまで進んだか・何が完了したかを一、二文で温かく具体的に振り返り、次に取るべき具体的な一歩を提示してください——具体的に述べ、「何をしますか」と聞き返さないこと。下記にある情報だけを使い、無いことは創作しないこと。手がかりが乏しくて判断できない場合は正直にそう述べ、見えるスレッドを挙げて選んでもらうこと。その後、通常どおり応答してください。)\n\n",
|
|
1604
1607
|
"report_rw_empty": "(このセッションの最初の返信の冒頭に、一行「🐾 おかえりなさい!このプロジェクトはまだ引き継ぐ進捗がありません。」と述べてから、通常どおり応答してください。)",
|
|
1605
1608
|
"report_rw_prompt": "プロジェクト:{project}(最終アクティブ {when})\n最近の作業(新しい順):{last_request}\n完了した進捗:{commits}\n未完了のToDo:{todos}",
|
|
1609
|
+
"report_rw_resume_cost": "この会話は {idle} 放置され、プロンプトキャッシュが切れています。次のメッセージで約 {tokens} トークンを再送信します。クォータを節約するには、先に /compact で圧縮してください。",
|
|
1606
1610
|
"report_rw_uncommitted": "前回の終了時に未コミット:{branch} ブランチに変更済み未コミットのファイルが {count} 件({files})",
|
|
1607
1611
|
"report_rw_diagnosis_reminder": "健診メモ: 直近では約 {waste_pct}% の無駄が{cause}から出ています。「直して」と言ってもらえれば、{path} の完全な診断を読んで対応します。",
|
|
1608
1612
|
"report_rw_diagnosis_reminder_explain": "健診メモ: 直近では約 {waste_pct}% の無駄が{cause}から出ています。「見せて」と言ってもらえれば、{path} の完全な診断を読み解いて説明と対策をお伝えします。",
|
|
@@ -2030,6 +2034,7 @@
|
|
|
2030
2034
|
"report_rw_inject_lead": "(이것은 진행 상황 인수인계입니다. 첫 답변 맨 앞에 먼저 한 줄 \"🐾 지난 작업을 불러왔어요! 이어서 가볼까요?\"라고 말한 뒤, 아래 단서를 그대로 읽지 말고 똑똑하고 사려 깊은 동료처럼 소화하세요: 먼저 사용자가 마지막에 실제로 무엇을 하고 있었는지 파악하고(\"최근 작업한 내용\"은 최신순이므로 맨 위 항목을 신뢰하고 오래된 것에 끌려가지 마세요), 지난번에 어디까지 했고 무엇을 완료했는지 한두 문장으로 따뜻하고 구체적으로 짚어 준 뒤, 이어서 취해야 할 구체적인 다음 단계 하나를 제시하세요 — 구체적으로 말하고 \"무엇을 할까요\"라고 되묻지 마세요. 아래 있는 내용만 사용하고 없는 것은 지어내지 마세요. 단서가 너무 적어 판단하기 어려우면 솔직히 그렇게 말하고 보이는 갈래들을 나열해 고르게 하세요. 그런 다음 평소대로 응답하세요.)\n\n",
|
|
2031
2035
|
"report_rw_empty": "(이 세션의 첫 답변 맨 앞에 한 줄 \"🐾 돌아오셨네요! 이 프로젝트는 아직 이어갈 내용이 없어요.\"라고 말한 뒤 평소대로 응답하세요.)",
|
|
2032
2036
|
"report_rw_prompt": "프로젝트: {project} (마지막 활동 {when})\n최근 작업한 내용 (최신순): {last_request}\n완료한 진행: {commits}\n미완료 할 일: {todos}",
|
|
2037
|
+
"report_rw_resume_cost": "이 대화는 {idle} 동안 방치되어 프롬프트 캐시가 만료되었습니다. 다음 메시지에서 약 {tokens} 토큰을 다시 보냅니다. 할당량을 아끼려면 먼저 /compact로 압축하세요.",
|
|
2033
2038
|
"report_rw_uncommitted": "지난번 종료 시 미커밋 파일: {branch} 브랜치에 {count}개 파일이 커밋되지 않은 채 남아 있었습니다 ({files})",
|
|
2034
2039
|
"report_rw_diagnosis_reminder": "진단 알림: 최근 낭비 약 {waste_pct}%의 주된 원인은 {cause}입니다. \"고쳐줘\"라고 말하면 {path}의 전체 진단을 읽고 바로 처리할게요.",
|
|
2035
2040
|
"report_rw_diagnosis_reminder_explain": "진단 알림: 최근 낭비 약 {waste_pct}%의 주된 원인은 {cause}입니다. \"보여줘\"라고 말하면 {path}의 전체 진단을 읽고 자세히 설명해 드릴게요.",
|
|
@@ -59,8 +59,8 @@ CODEX_CONFIG = setup_hook.CODEX_CONFIG
|
|
|
59
59
|
# session. Off by default: enabled only via the menu toggle, never by self_heal.
|
|
60
60
|
RESUME_HOOK_TARGET = Path(os.path.expanduser("~/.claude/usage-session-resume.py"))
|
|
61
61
|
RESUME_PROMPT_SIDECAR = Path(os.path.expanduser("~/.claude/usage-resume-prompt.json"))
|
|
62
|
-
RESUME_HOOK_VERSION = "1.
|
|
63
|
-
RESUME_MATCHER = "startup|clear"
|
|
62
|
+
RESUME_HOOK_VERSION = "1.10"
|
|
63
|
+
RESUME_MATCHER = "startup|clear|resume"
|
|
64
64
|
RESUME_LANGS = ("zh-TW", "zh-CN", "en", "ja", "ko")
|
|
65
65
|
_RESUME_MARKER = "usage-session-resume"
|
|
66
66
|
_RESUME_MARKERS = (_RESUME_MARKER, "usage_session_resume")
|
|
@@ -299,6 +299,7 @@ def _write_resume_sidecar() -> None:
|
|
|
299
299
|
lead = table.get("report_rw_inject_lead") or en.get("report_rw_inject_lead") or ""
|
|
300
300
|
empty = table.get("report_rw_empty") or en.get("report_rw_empty") or ""
|
|
301
301
|
uncommitted = table.get("report_rw_uncommitted") or en.get("report_rw_uncommitted") or ""
|
|
302
|
+
resume_cost = table.get("report_rw_resume_cost") or en.get("report_rw_resume_cost") or ""
|
|
302
303
|
diagnosis_reminder = (
|
|
303
304
|
table.get("report_rw_diagnosis_reminder")
|
|
304
305
|
or en.get("report_rw_diagnosis_reminder")
|
|
@@ -329,6 +330,7 @@ def _write_resume_sidecar() -> None:
|
|
|
329
330
|
"lead": lead,
|
|
330
331
|
"empty": empty,
|
|
331
332
|
"uncommitted": uncommitted,
|
|
333
|
+
"resume_cost": resume_cost,
|
|
332
334
|
"diagnosis_reminder": diagnosis_reminder,
|
|
333
335
|
"diagnosis_reminder_explain": diagnosis_reminder_explain,
|
|
334
336
|
"diagnosis_default_cause": diagnosis_default_cause,
|
|
@@ -837,6 +839,7 @@ def _self_heal_resume() -> None:
|
|
|
837
839
|
script/sidecar and update a stale script. Never enables it on its own."""
|
|
838
840
|
if not is_resume_enabled():
|
|
839
841
|
return
|
|
842
|
+
_migrate_resume_matcher_if_needed()
|
|
840
843
|
_migrate_resume_command_if_needed()
|
|
841
844
|
missing = _missing_resume_artifacts()
|
|
842
845
|
if missing:
|
|
@@ -919,6 +922,41 @@ def _self_heal_terse_reminder() -> None:
|
|
|
919
922
|
)
|
|
920
923
|
|
|
921
924
|
|
|
925
|
+
def _migrate_resume_matcher_if_needed() -> None:
|
|
926
|
+
settings = _load_settings()
|
|
927
|
+
entries = _session_start_list(settings)
|
|
928
|
+
if not entries:
|
|
929
|
+
return
|
|
930
|
+
changed = False
|
|
931
|
+
updated: list[Any] = []
|
|
932
|
+
for entry in entries:
|
|
933
|
+
if not isinstance(entry, dict) or not _is_resume_entry(entry):
|
|
934
|
+
updated.append(entry)
|
|
935
|
+
continue
|
|
936
|
+
if entry.get("matcher") == RESUME_MATCHER:
|
|
937
|
+
updated.append(entry)
|
|
938
|
+
continue
|
|
939
|
+
hooks = entry.get("hooks")
|
|
940
|
+
if not isinstance(hooks, list):
|
|
941
|
+
updated.append(entry)
|
|
942
|
+
continue
|
|
943
|
+
ours = [
|
|
944
|
+
hook
|
|
945
|
+
for hook in hooks
|
|
946
|
+
if isinstance(hook, dict)
|
|
947
|
+
and isinstance(hook.get("command"), str)
|
|
948
|
+
and _command_owns_script(hook["command"], _RESUME_MARKERS)
|
|
949
|
+
]
|
|
950
|
+
others = [hook for hook in hooks if hook not in ours]
|
|
951
|
+
if others:
|
|
952
|
+
updated.append({**entry, "hooks": others})
|
|
953
|
+
updated.append({**entry, "matcher": RESUME_MATCHER, "hooks": ours})
|
|
954
|
+
changed = True
|
|
955
|
+
if changed:
|
|
956
|
+
settings["hooks"]["SessionStart"] = updated
|
|
957
|
+
_save_settings(settings)
|
|
958
|
+
|
|
959
|
+
|
|
922
960
|
def _migrate_resume_command_if_needed() -> None:
|
|
923
961
|
settings = _load_settings()
|
|
924
962
|
entries = _session_start_list(settings)
|
|
@@ -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.7"
|
|
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.
|
|
@@ -15,7 +15,7 @@ import sys
|
|
|
15
15
|
import time
|
|
16
16
|
from collections import OrderedDict
|
|
17
17
|
from collections.abc import Iterable
|
|
18
|
-
from dataclasses import dataclass
|
|
18
|
+
from dataclasses import dataclass, replace
|
|
19
19
|
from datetime import UTC, datetime, timedelta
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
from typing import Any
|
|
@@ -55,7 +55,7 @@ class _FileCacheEntry:
|
|
|
55
55
|
_file_cache: OrderedDict[Path, _FileCacheEntry] = OrderedDict()
|
|
56
56
|
|
|
57
57
|
HISTORY_CACHE_PATH = Path(os.path.expanduser("~/.usage/history_jsonl_cache.json"))
|
|
58
|
-
_HISTORY_JSONL_CACHE_SCHEMA =
|
|
58
|
+
_HISTORY_JSONL_CACHE_SCHEMA = 5
|
|
59
59
|
_disk_cache_seeded = False
|
|
60
60
|
_DISK_CACHE_FLUSH_INTERVAL_S = 300.0
|
|
61
61
|
_disk_cache_dirty = False
|
|
@@ -319,7 +319,7 @@ def _parse_complete_lines(
|
|
|
319
319
|
digest: Any,
|
|
320
320
|
confirmed_offset: int,
|
|
321
321
|
) -> int:
|
|
322
|
-
|
|
322
|
+
index = {_dedup_key(entry): i for i, entry in enumerate(parsed_entries)}
|
|
323
323
|
while True:
|
|
324
324
|
line_start = int(file.tell())
|
|
325
325
|
line, too_long = read_bounded_jsonl_line(file)
|
|
@@ -337,9 +337,15 @@ def _parse_complete_lines(
|
|
|
337
337
|
if parsed_entries_for_line is not None:
|
|
338
338
|
for entry in parsed_entries_for_line:
|
|
339
339
|
dedup_key = _dedup_key(entry)
|
|
340
|
-
|
|
340
|
+
kept_at = index.get(dedup_key)
|
|
341
|
+
if kept_at is not None:
|
|
342
|
+
# Subagent transcripts log one request several times while it streams,
|
|
343
|
+
# with output_tokens still growing, so keep the largest, not the first.
|
|
344
|
+
kept = parsed_entries[kept_at]
|
|
345
|
+
if entry.output_tokens > kept.output_tokens:
|
|
346
|
+
parsed_entries[kept_at] = replace(kept, output_tokens=entry.output_tokens)
|
|
341
347
|
continue
|
|
342
|
-
|
|
348
|
+
index[dedup_key] = len(parsed_entries)
|
|
343
349
|
parsed_entries.append(entry)
|
|
344
350
|
|
|
345
351
|
|
|
@@ -960,7 +960,7 @@ class AppDelegate(NSObject):
|
|
|
960
960
|
reset=row.reset_text,
|
|
961
961
|
)
|
|
962
962
|
)
|
|
963
|
-
content.setSound_(sound_cls.
|
|
963
|
+
content.setSound_(menubar_notify.notification_sound(sound_cls, event.kind))
|
|
964
964
|
request = request_cls.requestWithIdentifier_content_trigger_(
|
|
965
965
|
f"usage.{event.channel}.{event.kind}.{int(time.time() * 1000)}",
|
|
966
966
|
content,
|
|
@@ -45,6 +45,16 @@ def user_notification_classes() -> tuple[Any, Any, Any]:
|
|
|
45
45
|
return UNMutableNotificationContent, UNNotificationRequest, UNNotificationSound
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
ALERT_SOUND = "usage_uhoh.wav"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def notification_sound(sound_cls: Any, kind: str) -> Any:
|
|
52
|
+
# "restored" is good news; only warn/depleted get the uh-oh.
|
|
53
|
+
if kind == "restored":
|
|
54
|
+
return sound_cls.defaultSound()
|
|
55
|
+
return sound_cls.soundNamed_(ALERT_SOUND)
|
|
56
|
+
|
|
57
|
+
|
|
48
58
|
def register_user_notification_block_metadata() -> None:
|
|
49
59
|
objc.registerMetaDataForSelector(
|
|
50
60
|
b"UNUserNotificationCenter",
|
|
@@ -485,6 +485,12 @@ def _fallback_pricing() -> PricingTable:
|
|
|
485
485
|
"cache_creation_input_token_cost": 2.5e-6,
|
|
486
486
|
"cache_read_input_token_cost": 0.2e-6,
|
|
487
487
|
},
|
|
488
|
+
"claude-sonnet-5-5": {
|
|
489
|
+
"input_cost_per_token": 2e-6,
|
|
490
|
+
"output_cost_per_token": 10e-6,
|
|
491
|
+
"cache_creation_input_token_cost": 2.5e-6,
|
|
492
|
+
"cache_read_input_token_cost": 0.2e-6,
|
|
493
|
+
},
|
|
488
494
|
"claude-fable-5": {
|
|
489
495
|
"input_cost_per_token": 10e-6,
|
|
490
496
|
"output_cost_per_token": 50e-6,
|
|
@@ -200,3 +200,55 @@ def test_project_from_cwd_groups_repo_subfolders_under_repo_name(tmp_path: Path)
|
|
|
200
200
|
assert claude.project_from_cwd(str(repo / "tests" / "fixtures")) == "my-repo"
|
|
201
201
|
assert claude.project_from_cwd(str(repo)) == "my-repo"
|
|
202
202
|
assert claude.project_from_cwd(str(plain)) == "notes"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_parse_jsonl_keeps_largest_output_of_streamed_duplicate(tmp_path: Path) -> None:
|
|
206
|
+
# Subagent transcripts repeat one request while it streams; the last line is final.
|
|
207
|
+
path = tmp_path / "agent-1.jsonl"
|
|
208
|
+
lines = []
|
|
209
|
+
for output_tokens in (6, 118, 40):
|
|
210
|
+
lines.append(
|
|
211
|
+
json.dumps(
|
|
212
|
+
{
|
|
213
|
+
"type": "assistant",
|
|
214
|
+
"timestamp": "2026-01-01T00:00:00Z",
|
|
215
|
+
"sessionId": "session-1",
|
|
216
|
+
"requestId": "request-1",
|
|
217
|
+
"message": {
|
|
218
|
+
"id": "message-1",
|
|
219
|
+
"model": "claude-sonnet",
|
|
220
|
+
"usage": {"input_tokens": 1, "output_tokens": output_tokens},
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
226
|
+
entries: list[Any] = []
|
|
227
|
+
|
|
228
|
+
claude.parse_jsonl(path, "demo", entries, set(), None)
|
|
229
|
+
|
|
230
|
+
assert [entry.output_tokens for entry in entries] == [118]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_parse_jsonl_never_merges_output_across_entries_without_ids(tmp_path: Path) -> None:
|
|
234
|
+
path = tmp_path / "anon.jsonl"
|
|
235
|
+
lines = [
|
|
236
|
+
json.dumps(
|
|
237
|
+
{
|
|
238
|
+
"type": "assistant",
|
|
239
|
+
"timestamp": "2026-01-01T00:00:00Z",
|
|
240
|
+
"sessionId": session_id,
|
|
241
|
+
"message": {
|
|
242
|
+
"model": "claude-sonnet",
|
|
243
|
+
"usage": {"input_tokens": 1, "output_tokens": output_tokens},
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
for session_id, output_tokens in (("s1", 6), ("s2", 118))
|
|
248
|
+
]
|
|
249
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
250
|
+
entries: list[Any] = []
|
|
251
|
+
|
|
252
|
+
claude.parse_jsonl(path, "demo", entries, set(), None)
|
|
253
|
+
|
|
254
|
+
assert [(entry.session_id, entry.output_tokens) for entry in entries] == [("s1", 6)]
|
|
@@ -116,6 +116,32 @@ def test_load_entries_keeps_first_duplicate_within_file(tmp_path: Path) -> None:
|
|
|
116
116
|
assert [entry.input_tokens for entry in history_loader._file_cache[path].entries] == [1]
|
|
117
117
|
|
|
118
118
|
|
|
119
|
+
def test_load_entries_keeps_largest_output_of_streamed_duplicate(tmp_path: Path) -> None:
|
|
120
|
+
# Subagent transcripts repeat one request while it streams; the last line is final.
|
|
121
|
+
path = tmp_path / "agent-1.jsonl"
|
|
122
|
+
path.write_text(
|
|
123
|
+
"\n".join([_line(output_tokens=6), _line(output_tokens=118), _line(output_tokens=40)]),
|
|
124
|
+
encoding="utf-8",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
entries = history_loader.load_entries(jsonl_paths=[path])
|
|
128
|
+
|
|
129
|
+
assert [entry.output_tokens for entry in entries] == [118]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_incremental_parse_upgrades_streamed_output_tokens(tmp_path: Path) -> None:
|
|
133
|
+
path = tmp_path / "agent-1.jsonl"
|
|
134
|
+
path.write_text(_line(output_tokens=6) + "\n", encoding="utf-8")
|
|
135
|
+
first = history_loader.load_entries(jsonl_paths=[path])
|
|
136
|
+
with path.open("a", encoding="utf-8") as file:
|
|
137
|
+
file.write(_line(output_tokens=118) + "\n")
|
|
138
|
+
|
|
139
|
+
entries = history_loader.load_entries(jsonl_paths=[path])
|
|
140
|
+
|
|
141
|
+
assert [entry.output_tokens for entry in entries] == [118]
|
|
142
|
+
assert [entry.output_tokens for entry in first] == [6]
|
|
143
|
+
|
|
144
|
+
|
|
119
145
|
def test_incremental_parse_deduplicates_against_cached_entries(tmp_path: Path) -> None:
|
|
120
146
|
path = tmp_path / "history.jsonl"
|
|
121
147
|
path.write_text(_line(input_tokens=1) + "\n", encoding="utf-8")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import wave
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from menubar import notify
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _FakeSound:
|
|
18
|
+
@staticmethod
|
|
19
|
+
def defaultSound() -> str:
|
|
20
|
+
return "default"
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def soundNamed_(name: str) -> str:
|
|
24
|
+
return f"named:{name}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_warn_and_depleted_use_uhoh_but_restored_keeps_default() -> None:
|
|
28
|
+
assert notify.notification_sound(_FakeSound, "warn") == "named:usage_uhoh.wav"
|
|
29
|
+
assert notify.notification_sound(_FakeSound, "depleted") == "named:usage_uhoh.wav"
|
|
30
|
+
assert notify.notification_sound(_FakeSound, "restored") == "default"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_alert_sound_is_bundled_and_short() -> None:
|
|
34
|
+
sound = ROOT / "assets" / notify.ALERT_SOUND
|
|
35
|
+
assert f"assets/{notify.ALERT_SOUND}" in (ROOT / "setup_app.py").read_text()
|
|
36
|
+
with wave.open(str(sound)) as w:
|
|
37
|
+
assert w.getnframes() / w.getframerate() < 30
|
|
@@ -379,6 +379,13 @@ def test_fallback_pricing_contains_expected_models() -> None:
|
|
|
379
379
|
"cache_read_input_token_cost": 0.2e-6,
|
|
380
380
|
}
|
|
381
381
|
assert pricing._resolve_model_key("claude-opus-5", fallback) == "claude-opus-5"
|
|
382
|
+
assert fallback["claude-sonnet-5-5"] == {
|
|
383
|
+
"input_cost_per_token": 2e-6,
|
|
384
|
+
"output_cost_per_token": 10e-6,
|
|
385
|
+
"cache_creation_input_token_cost": 2.5e-6,
|
|
386
|
+
"cache_read_input_token_cost": 0.2e-6,
|
|
387
|
+
}
|
|
388
|
+
assert pricing._resolve_model_key("claude-sonnet-5", fallback) == "claude-sonnet-5"
|
|
382
389
|
assert fallback["claude-fable-5-1"] == {
|
|
383
390
|
"input_cost_per_token": 10e-6,
|
|
384
391
|
"output_cost_per_token": 50e-6,
|
|
@@ -702,8 +702,12 @@ def test_main_emits_additional_context(
|
|
|
702
702
|
assert "wire up the new endpoint" in out["hookSpecificOutput"]["additionalContext"]
|
|
703
703
|
|
|
704
704
|
|
|
705
|
+
@pytest.mark.parametrize("source", ["startup", "clear", None])
|
|
705
706
|
def test_main_emits_greeting_when_no_progress(
|
|
706
|
-
|
|
707
|
+
source: str | None,
|
|
708
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
709
|
+
tmp_path: Path,
|
|
710
|
+
capsys: pytest.CaptureFixture[str],
|
|
707
711
|
) -> None:
|
|
708
712
|
monkeypatch.setenv("USAGE_LANG", "en")
|
|
709
713
|
_sidecar(tmp_path, monkeypatch)
|
|
@@ -711,7 +715,9 @@ def test_main_emits_greeting_when_no_progress(
|
|
|
711
715
|
# Brand-new project: only the current transcript, nothing previous to report.
|
|
712
716
|
current = project / "current.jsonl"
|
|
713
717
|
current.write_text("", encoding="utf-8")
|
|
714
|
-
payload = json.dumps(
|
|
718
|
+
payload = json.dumps(
|
|
719
|
+
{"source": source, "transcript_path": str(current), "cwd": "/Users/me/Developer/myproj"}
|
|
720
|
+
)
|
|
715
721
|
monkeypatch.setattr("sys.stdin", _FakeStdin(payload))
|
|
716
722
|
|
|
717
723
|
assert mod.main() == 0
|
|
@@ -719,6 +725,158 @@ def test_main_emits_greeting_when_no_progress(
|
|
|
719
725
|
assert out["hookSpecificOutput"]["additionalContext"] == "GREETING::"
|
|
720
726
|
|
|
721
727
|
|
|
728
|
+
@pytest.mark.parametrize(
|
|
729
|
+
("lang", "expected"),
|
|
730
|
+
[
|
|
731
|
+
(
|
|
732
|
+
"zh-TW",
|
|
733
|
+
"這個對話放了 3h12m,快取已過期,下一句會重送約 180k token。"
|
|
734
|
+
"想省額度可以先打 /compact 壓縮再聊。",
|
|
735
|
+
),
|
|
736
|
+
(
|
|
737
|
+
"en",
|
|
738
|
+
"This conversation sat idle for 3h12m and its prompt cache has expired, "
|
|
739
|
+
"so your next message re-sends about 180k tokens. Run /compact first "
|
|
740
|
+
"to use less quota.",
|
|
741
|
+
),
|
|
742
|
+
],
|
|
743
|
+
)
|
|
744
|
+
def test_main_resume_cost_is_user_only(
|
|
745
|
+
lang: str,
|
|
746
|
+
expected: str,
|
|
747
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
748
|
+
tmp_path: Path,
|
|
749
|
+
capsys: pytest.CaptureFixture[str],
|
|
750
|
+
) -> None:
|
|
751
|
+
monkeypatch.setenv("USAGE_LANG", lang)
|
|
752
|
+
_sidecar(tmp_path, monkeypatch)
|
|
753
|
+
monkeypatch.setattr(
|
|
754
|
+
sys,
|
|
755
|
+
"stdin",
|
|
756
|
+
_FakeStdin(
|
|
757
|
+
json.dumps(
|
|
758
|
+
{
|
|
759
|
+
"source": "resume",
|
|
760
|
+
"transcript_path": "/does/not/exist.jsonl",
|
|
761
|
+
"cwd": "/does/not/exist",
|
|
762
|
+
"seconds_since_last_response": 11520,
|
|
763
|
+
"context_tokens": 180000,
|
|
764
|
+
"prompt_cache_likely_expired": True,
|
|
765
|
+
}
|
|
766
|
+
)
|
|
767
|
+
),
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
assert mod.main() == 0
|
|
771
|
+
assert json.loads(capsys.readouterr().out) == {"systemMessage": expected}
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
@pytest.mark.parametrize(
|
|
775
|
+
"changes",
|
|
776
|
+
[
|
|
777
|
+
{"prompt_cache_likely_expired": False},
|
|
778
|
+
{"context_tokens": 20000},
|
|
779
|
+
{"context_tokens": None},
|
|
780
|
+
{"seconds_since_last_response": None},
|
|
781
|
+
{"context_tokens": True},
|
|
782
|
+
{"seconds_since_last_response": True},
|
|
783
|
+
{"seconds_since_last_response": -1},
|
|
784
|
+
],
|
|
785
|
+
)
|
|
786
|
+
def test_main_resume_cost_invalid_input_is_silent(
|
|
787
|
+
changes: dict[str, object],
|
|
788
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
789
|
+
tmp_path: Path,
|
|
790
|
+
capsys: pytest.CaptureFixture[str],
|
|
791
|
+
) -> None:
|
|
792
|
+
_sidecar(tmp_path, monkeypatch)
|
|
793
|
+
payload: dict[str, object] = {
|
|
794
|
+
"source": "resume",
|
|
795
|
+
"seconds_since_last_response": 11520,
|
|
796
|
+
"context_tokens": 180000,
|
|
797
|
+
"prompt_cache_likely_expired": True,
|
|
798
|
+
}
|
|
799
|
+
payload.update(changes)
|
|
800
|
+
monkeypatch.setattr(sys, "stdin", _FakeStdin(json.dumps(payload)))
|
|
801
|
+
|
|
802
|
+
assert mod.main() == 0
|
|
803
|
+
assert capsys.readouterr().out == ""
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
@pytest.mark.parametrize(
|
|
807
|
+
"missing", ["prompt_cache_likely_expired", "context_tokens", "seconds_since_last_response"]
|
|
808
|
+
)
|
|
809
|
+
def test_main_resume_cost_missing_field_is_silent(
|
|
810
|
+
missing: str,
|
|
811
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
812
|
+
tmp_path: Path,
|
|
813
|
+
capsys: pytest.CaptureFixture[str],
|
|
814
|
+
) -> None:
|
|
815
|
+
_sidecar(tmp_path, monkeypatch)
|
|
816
|
+
payload = {
|
|
817
|
+
"source": "resume",
|
|
818
|
+
"prompt_cache_likely_expired": True,
|
|
819
|
+
"context_tokens": 180000,
|
|
820
|
+
"seconds_since_last_response": 11520,
|
|
821
|
+
}
|
|
822
|
+
del payload[missing]
|
|
823
|
+
monkeypatch.setattr(sys, "stdin", _FakeStdin(json.dumps(payload)))
|
|
824
|
+
|
|
825
|
+
assert mod.main() == 0
|
|
826
|
+
assert capsys.readouterr().out == ""
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def test_main_resume_cost_uses_sidecar_and_float_idle(
|
|
830
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
831
|
+
tmp_path: Path,
|
|
832
|
+
capsys: pytest.CaptureFixture[str],
|
|
833
|
+
) -> None:
|
|
834
|
+
monkeypatch.setenv("USAGE_LANG", "en")
|
|
835
|
+
sidecar = tmp_path / "prompt.json"
|
|
836
|
+
sidecar.write_text(json.dumps({"en": {"resume_cost": "idle={idle} tokens={tokens}"}}))
|
|
837
|
+
monkeypatch.setattr(mod, "PROMPT_SIDECAR", sidecar)
|
|
838
|
+
monkeypatch.setattr(
|
|
839
|
+
sys,
|
|
840
|
+
"stdin",
|
|
841
|
+
_FakeStdin(
|
|
842
|
+
json.dumps(
|
|
843
|
+
{
|
|
844
|
+
"source": "resume",
|
|
845
|
+
"seconds_since_last_response": 172800.5,
|
|
846
|
+
"context_tokens": 1_200_000,
|
|
847
|
+
"prompt_cache_likely_expired": True,
|
|
848
|
+
}
|
|
849
|
+
)
|
|
850
|
+
),
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
assert mod.main() == 0
|
|
854
|
+
assert json.loads(capsys.readouterr().out) == {"systemMessage": "idle=2d0h tokens=1.2M"}
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def test_main_fork_is_silent(
|
|
858
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
859
|
+
capsys: pytest.CaptureFixture[str],
|
|
860
|
+
) -> None:
|
|
861
|
+
monkeypatch.setattr(
|
|
862
|
+
sys,
|
|
863
|
+
"stdin",
|
|
864
|
+
_FakeStdin(
|
|
865
|
+
json.dumps(
|
|
866
|
+
{
|
|
867
|
+
"source": "fork",
|
|
868
|
+
"seconds_since_last_response": 11520,
|
|
869
|
+
"context_tokens": 180000,
|
|
870
|
+
"prompt_cache_likely_expired": True,
|
|
871
|
+
}
|
|
872
|
+
)
|
|
873
|
+
),
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
assert mod.main() == 0
|
|
877
|
+
assert capsys.readouterr().out == ""
|
|
878
|
+
|
|
879
|
+
|
|
722
880
|
def test_main_reads_utf8_bytes_when_stdin_uses_cp950(
|
|
723
881
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
724
882
|
) -> None:
|