usage-cli 0.34.14__tar.gz → 0.34.15__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. {usage_cli-0.34.14/usage_cli.egg-info → usage_cli-0.34.15}/PKG-INFO +1 -1
  2. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/agy_quota_probe.py +20 -2
  3. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/agy.py +5 -0
  4. {usage_cli-0.34.14 → usage_cli-0.34.15}/pyproject.toml +1 -1
  5. {usage_cli-0.34.14 → usage_cli-0.34.15}/quota/agy_window_keeper.py +1 -1
  6. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_agy_quota_probe.py +68 -1
  7. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_agy_window_keeper.py +35 -1
  8. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_keeper_outcome.py +4 -2
  9. usage_cli-0.34.15/tests/test_network.py +97 -0
  10. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_usage_cli.py +2 -1
  11. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_wintray.py +76 -2
  12. {usage_cli-0.34.14 → usage_cli-0.34.15/usage_cli.egg-info}/PKG-INFO +1 -1
  13. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_common/network.py +25 -3
  14. {usage_cli-0.34.14 → usage_cli-0.34.15}/wintray/app.py +14 -5
  15. usage_cli-0.34.14/tests/test_network.py +0 -41
  16. {usage_cli-0.34.14 → usage_cli-0.34.15}/LICENSE +0 -0
  17. {usage_cli-0.34.14 → usage_cli-0.34.15}/README.md +0 -0
  18. {usage_cli-0.34.14 → usage_cli-0.34.15}/THIRD_PARTY_NOTICES.md +0 -0
  19. {usage_cli-0.34.14 → usage_cli-0.34.15}/adapters/__init__.py +0 -0
  20. {usage_cli-0.34.14 → usage_cli-0.34.15}/adapters/agy.py +0 -0
  21. {usage_cli-0.34.14 → usage_cli-0.34.15}/adapters/claude.py +0 -0
  22. {usage_cli-0.34.14 → usage_cli-0.34.15}/adapters/codex.py +0 -0
  23. {usage_cli-0.34.14 → usage_cli-0.34.15}/adapters/grok.py +0 -0
  24. {usage_cli-0.34.14 → usage_cli-0.34.15}/adapters/muse.py +0 -0
  25. {usage_cli-0.34.14 → usage_cli-0.34.15}/adapters/rate_limits.py +0 -0
  26. {usage_cli-0.34.14 → usage_cli-0.34.15}/adapters/registry.py +0 -0
  27. {usage_cli-0.34.14 → usage_cli-0.34.15}/adapters/types.py +0 -0
  28. {usage_cli-0.34.14 → usage_cli-0.34.15}/analyzer/__init__.py +0 -0
  29. {usage_cli-0.34.14 → usage_cli-0.34.15}/analyzer/aggregator.py +0 -0
  30. {usage_cli-0.34.14 → usage_cli-0.34.15}/analyzer/blocks.py +0 -0
  31. {usage_cli-0.34.14 → usage_cli-0.34.15}/analyzer/diagnoser.py +0 -0
  32. {usage_cli-0.34.14 → usage_cli-0.34.15}/analyzer/diagnosis_snapshot.py +0 -0
  33. {usage_cli-0.34.14 → usage_cli-0.34.15}/analyzer/dispatch_ledger.py +0 -0
  34. {usage_cli-0.34.14 → usage_cli-0.34.15}/analyzer/history_persist.py +0 -0
  35. {usage_cli-0.34.14 → usage_cli-0.34.15}/analyzer/insights.py +0 -0
  36. {usage_cli-0.34.14 → usage_cli-0.34.15}/analyzer/ledger_backfill.py +0 -0
  37. {usage_cli-0.34.14 → usage_cli-0.34.15}/analyzer/ledger_legacy_backfill.py +0 -0
  38. {usage_cli-0.34.14 → usage_cli-0.34.15}/analyzer/persona_loader.py +0 -0
  39. {usage_cli-0.34.14 → usage_cli-0.34.15}/analyzer/reporter.py +0 -0
  40. {usage_cli-0.34.14 → usage_cli-0.34.15}/analyzer/subscription.py +0 -0
  41. {usage_cli-0.34.14 → usage_cli-0.34.15}/analyzer/usage_snapshot.py +0 -0
  42. {usage_cli-0.34.14 → usage_cli-0.34.15}/i18n.json +0 -0
  43. {usage_cli-0.34.14 → usage_cli-0.34.15}/installer/__init__.py +0 -0
  44. {usage_cli-0.34.14 → usage_cli-0.34.15}/installer/claude_pane.py +0 -0
  45. {usage_cli-0.34.14 → usage_cli-0.34.15}/installer/codex_hook_trust.py +0 -0
  46. {usage_cli-0.34.14 → usage_cli-0.34.15}/installer/login_item.py +0 -0
  47. {usage_cli-0.34.14 → usage_cli-0.34.15}/installer/session_hooks.py +0 -0
  48. {usage_cli-0.34.14 → usage_cli-0.34.15}/installer/setup_hook.py +0 -0
  49. {usage_cli-0.34.14 → usage_cli-0.34.15}/installer/statusline_settings.py +0 -0
  50. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/__init__.py +0 -0
  51. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/_shared_cache_file.py +0 -0
  52. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/agy_disk_cache.py +0 -0
  53. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/agy_loader.py +0 -0
  54. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/cache_quarantine.py +0 -0
  55. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/chromium_cache.py +0 -0
  56. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/claude_desktop.py +0 -0
  57. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/claude_paths.py +0 -0
  58. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/claude_usage.py +0 -0
  59. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/codex_disk_cache.py +0 -0
  60. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/codex_events.py +0 -0
  61. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/codex_fork_replay.py +0 -0
  62. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/codex_loader.py +0 -0
  63. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/codex_paths.py +0 -0
  64. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/disk_cache_common.py +0 -0
  65. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/disk_cache_lifecycle.py +0 -0
  66. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/grok_loader.py +0 -0
  67. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/grok_quota_probe.py +0 -0
  68. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/history_disk_cache.py +0 -0
  69. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/history_loader.py +0 -0
  70. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/jsonl_limits.py +0 -0
  71. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/jsonl_utils.py +0 -0
  72. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/muse_loader.py +0 -0
  73. {usage_cli-0.34.14 → usage_cli-0.34.15}/loaders/muse_quota_probe.py +0 -0
  74. {usage_cli-0.34.14 → usage_cli-0.34.15}/main.py +0 -0
  75. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/__init__.py +0 -0
  76. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/actions.py +0 -0
  77. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/app.py +0 -0
  78. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/chrome.py +0 -0
  79. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/fsevents_watch.py +0 -0
  80. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/grok.py +0 -0
  81. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/manual_refresh.py +0 -0
  82. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/menu.py +0 -0
  83. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/muse.py +0 -0
  84. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/new_badge.py +0 -0
  85. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/notify.py +0 -0
  86. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/popover.py +0 -0
  87. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/prefs.py +0 -0
  88. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/quota_countdown.py +0 -0
  89. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/refresh.py +0 -0
  90. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/state.py +0 -0
  91. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/title.py +0 -0
  92. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/update.py +0 -0
  93. {usage_cli-0.34.14 → usage_cli-0.34.15}/menubar/whats_new.py +0 -0
  94. {usage_cli-0.34.14 → usage_cli-0.34.15}/panels/__init__.py +0 -0
  95. {usage_cli-0.34.14 → usage_cli-0.34.15}/panels/base.py +0 -0
  96. {usage_cli-0.34.14 → usage_cli-0.34.15}/panels/catalog.py +0 -0
  97. {usage_cli-0.34.14 → usage_cli-0.34.15}/panels/dynamic_height.py +0 -0
  98. {usage_cli-0.34.14 → usage_cli-0.34.15}/panels/panel_scale.py +0 -0
  99. {usage_cli-0.34.14 → usage_cli-0.34.15}/panels/panel_window.py +0 -0
  100. {usage_cli-0.34.14 → usage_cli-0.34.15}/panels/panel_window_state.py +0 -0
  101. {usage_cli-0.34.14 → usage_cli-0.34.15}/panels/payload.py +0 -0
  102. {usage_cli-0.34.14 → usage_cli-0.34.15}/panels/web_panel.py +0 -0
  103. {usage_cli-0.34.14 → usage_cli-0.34.15}/panels/window_drag.py +0 -0
  104. {usage_cli-0.34.14 → usage_cli-0.34.15}/quota/__init__.py +0 -0
  105. {usage_cli-0.34.14 → usage_cli-0.34.15}/quota/burn_rate.py +0 -0
  106. {usage_cli-0.34.14 → usage_cli-0.34.15}/quota/codex_window_keeper.py +0 -0
  107. {usage_cli-0.34.14 → usage_cli-0.34.15}/quota/keeper_outcome.py +0 -0
  108. {usage_cli-0.34.14 → usage_cli-0.34.15}/quota/notifications.py +0 -0
  109. {usage_cli-0.34.14 → usage_cli-0.34.15}/quota/quota_snapshot.py +0 -0
  110. {usage_cli-0.34.14 → usage_cli-0.34.15}/quota/status_payload.py +0 -0
  111. {usage_cli-0.34.14 → usage_cli-0.34.15}/quota/usage_rate.py +0 -0
  112. {usage_cli-0.34.14 → usage_cli-0.34.15}/quota/window_keeper.py +0 -0
  113. {usage_cli-0.34.14 → usage_cli-0.34.15}/setup.cfg +0 -0
  114. {usage_cli-0.34.14 → usage_cli-0.34.15}/setup_app.py +0 -0
  115. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_adapters_claude.py +0 -0
  116. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_adapters_codex.py +0 -0
  117. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_adapters_rate_limits.py +0 -0
  118. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_aggregator.py +0 -0
  119. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_agy_adapter.py +0 -0
  120. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_agy_disk_cache.py +0 -0
  121. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_agy_loader.py +0 -0
  122. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_analyzer_blocks.py +0 -0
  123. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_analyzer_pipeline.py +0 -0
  124. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_app_version.py +0 -0
  125. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_burn_rate.py +0 -0
  126. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_cache_quarantine.py +0 -0
  127. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_check_release_version.py +0 -0
  128. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_check_upstream.py +0 -0
  129. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_chromium_cache.py +0 -0
  130. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_claude_desktop.py +0 -0
  131. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_claude_expired_windows.py +0 -0
  132. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_claude_pane.py +0 -0
  133. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_claude_paths.py +0 -0
  134. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_cli_lookup.py +0 -0
  135. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_codex_consistency.py +0 -0
  136. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_codex_hook_trust.py +0 -0
  137. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_codex_loader.py +0 -0
  138. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_codex_window_keeper.py +0 -0
  139. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_compact_cards.py +0 -0
  140. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_desktop_fresh_cache.py +0 -0
  141. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_desktop_quota_display.py +0 -0
  142. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_desktop_quota_review.py +0 -0
  143. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_diagnoser.py +0 -0
  144. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_disk_cache_shards.py +0 -0
  145. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_dispatch_ledger.py +0 -0
  146. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_doctor.py +0 -0
  147. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_dynamic_height.py +0 -0
  148. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_fsevents_watch.py +0 -0
  149. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_grok_loader.py +0 -0
  150. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_grok_quota_probe.py +0 -0
  151. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_history_loader.py +0 -0
  152. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_history_persist.py +0 -0
  153. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_hook_scripts_are_standalone.py +0 -0
  154. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_html_report_snapshot.py +0 -0
  155. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_i18n_format_fallback.py +0 -0
  156. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_i18n_key_parity.py +0 -0
  157. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_i18n_packaged_path.py +0 -0
  158. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_insights.py +0 -0
  159. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_jsonl_utils.py +0 -0
  160. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_keeper_menu.py +0 -0
  161. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_ledger_backfill.py +0 -0
  162. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_ledger_legacy_backfill.py +0 -0
  163. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_login_item.py +0 -0
  164. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_main.py +0 -0
  165. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_manual_refresh.py +0 -0
  166. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_menubar.py +0 -0
  167. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_menubar_agy.py +0 -0
  168. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_menubar_grok.py +0 -0
  169. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_menubar_muse.py +0 -0
  170. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_menubar_notify.py +0 -0
  171. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_menubar_permission.py +0 -0
  172. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_menubar_prefs.py +0 -0
  173. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_menubar_state.py +0 -0
  174. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_menubar_update.py +0 -0
  175. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_muse_coverage.py +0 -0
  176. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_muse_loader.py +0 -0
  177. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_muse_quota_probe.py +0 -0
  178. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_muse_review_regressions.py +0 -0
  179. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_new_badge.py +0 -0
  180. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_packaged_resources.py +0 -0
  181. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_packaging.py +0 -0
  182. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_panel_footer_layout.py +0 -0
  183. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_panel_scale.py +0 -0
  184. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_panel_window_state.py +0 -0
  185. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_panels.py +0 -0
  186. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_panels_lazy_import.py +0 -0
  187. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_persona_loader.py +0 -0
  188. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_pricing.py +0 -0
  189. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_project_resolver.py +0 -0
  190. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_quota_aware.py +0 -0
  191. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_quota_label.py +0 -0
  192. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_quota_threshold_menu.py +0 -0
  193. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_report_chart_redesign.py +0 -0
  194. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_report_cold_read.py +0 -0
  195. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_report_daily_chart.py +0 -0
  196. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_report_filter_js.py +0 -0
  197. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_report_headings.py +0 -0
  198. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_report_share_mask.py +0 -0
  199. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_reporter_build_data.py +0 -0
  200. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_service_status.py +0 -0
  201. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_session_resume.py +0 -0
  202. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_setup_app.py +0 -0
  203. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_setup_hook.py +0 -0
  204. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_setup_hook_coexistence.py +0 -0
  205. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_setup_hook_resume.py +0 -0
  206. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_setup_hook_terse.py +0 -0
  207. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_subprocess_utils.py +0 -0
  208. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_subscription.py +0 -0
  209. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_taskbar_menu.py +0 -0
  210. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_taskbar_obstacles.py +0 -0
  211. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_time_utils.py +0 -0
  212. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_tui.py +0 -0
  213. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_tui_sprite.py +0 -0
  214. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_ui_tables.py +0 -0
  215. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_update_checker.py +0 -0
  216. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_update_gate.py +0 -0
  217. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_update_release_notes.py +0 -0
  218. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_usage_client.py +0 -0
  219. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_usage_diagnosis_snapshot.py +0 -0
  220. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_usage_dir_sweeper.py +0 -0
  221. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_usage_lang.py +0 -0
  222. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_usage_logging.py +0 -0
  223. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_usage_notifications.py +0 -0
  224. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_usage_rate.py +0 -0
  225. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_usage_snapshot.py +0 -0
  226. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_usage_snapshot_replay.py +0 -0
  227. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_usage_statusline.py +0 -0
  228. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_usage_statusline_agy.py +0 -0
  229. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_usage_statusline_forwarder.py +0 -0
  230. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_usage_statusline_grok.py +0 -0
  231. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_usage_terse_mode.py +0 -0
  232. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_usage_terse_reminder.py +0 -0
  233. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_web_panel_payload.py +0 -0
  234. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_whats_new.py +0 -0
  235. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_wheel_packaging.py +0 -0
  236. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_win_login_item.py +0 -0
  237. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_window_keeper.py +0 -0
  238. {usage_cli-0.34.14 → usage_cli-0.34.15}/tests/test_windows_manifest.py +0 -0
  239. {usage_cli-0.34.14 → usage_cli-0.34.15}/tui/__init__.py +0 -0
  240. {usage_cli-0.34.14 → usage_cli-0.34.15}/tui/app.py +0 -0
  241. {usage_cli-0.34.14 → usage_cli-0.34.15}/tui/sprite.py +0 -0
  242. {usage_cli-0.34.14 → usage_cli-0.34.15}/ui/__init__.py +0 -0
  243. {usage_cli-0.34.14 → usage_cli-0.34.15}/ui/html_report.py +0 -0
  244. {usage_cli-0.34.14 → usage_cli-0.34.15}/ui/report_charts.py +0 -0
  245. {usage_cli-0.34.14 → usage_cli-0.34.15}/ui/report_daily_chart.py +0 -0
  246. {usage_cli-0.34.14 → usage_cli-0.34.15}/ui/report_filter.py +0 -0
  247. {usage_cli-0.34.14 → usage_cli-0.34.15}/ui/report_ledger.py +0 -0
  248. {usage_cli-0.34.14 → usage_cli-0.34.15}/ui/report_scripts.py +0 -0
  249. {usage_cli-0.34.14 → usage_cli-0.34.15}/ui/report_styles.py +0 -0
  250. {usage_cli-0.34.14 → usage_cli-0.34.15}/ui/tables.py +0 -0
  251. {usage_cli-0.34.14 → usage_cli-0.34.15}/updates/__init__.py +0 -0
  252. {usage_cli-0.34.14 → usage_cli-0.34.15}/updates/checker.py +0 -0
  253. {usage_cli-0.34.14 → usage_cli-0.34.15}/updates/gate.py +0 -0
  254. {usage_cli-0.34.14 → usage_cli-0.34.15}/updates/release_notes.py +0 -0
  255. {usage_cli-0.34.14 → usage_cli-0.34.15}/updates/whats_new.py +0 -0
  256. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_app/__init__.py +0 -0
  257. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_app/cli.py +0 -0
  258. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_app/doctor.py +0 -0
  259. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_cli.egg-info/SOURCES.txt +0 -0
  260. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_cli.egg-info/dependency_links.txt +0 -0
  261. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_cli.egg-info/entry_points.txt +0 -0
  262. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_cli.egg-info/requires.txt +0 -0
  263. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_cli.egg-info/top_level.txt +0 -0
  264. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_common/__init__.py +0 -0
  265. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_common/app_version.py +0 -0
  266. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_common/cli_lookup.py +0 -0
  267. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_common/i18n.py +0 -0
  268. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_common/prefs.py +0 -0
  269. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_common/pricing.py +0 -0
  270. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_common/project_resolver.py +0 -0
  271. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_common/service_status.py +0 -0
  272. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_common/subprocess_utils.py +0 -0
  273. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_common/time_utils.py +0 -0
  274. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_common/usage_dir_sweeper.py +0 -0
  275. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_common/usage_lang.py +0 -0
  276. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_common/usage_logging.py +0 -0
  277. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_hooks/__init__.py +0 -0
  278. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_hooks/usage_quota_aware.py +0 -0
  279. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_hooks/usage_session_resume.py +0 -0
  280. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_hooks/usage_statusline.py +0 -0
  281. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_hooks/usage_statusline_agy.py +0 -0
  282. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_hooks/usage_statusline_forwarder.py +0 -0
  283. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_hooks/usage_statusline_grok.py +0 -0
  284. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_hooks/usage_terse_mode.py +0 -0
  285. {usage_cli-0.34.14 → usage_cli-0.34.15}/usage_hooks/usage_terse_reminder.py +0 -0
  286. {usage_cli-0.34.14 → usage_cli-0.34.15}/wintray/__init__.py +0 -0
  287. {usage_cli-0.34.14 → usage_cli-0.34.15}/wintray/login_item.py +0 -0
  288. {usage_cli-0.34.14 → usage_cli-0.34.15}/wintray/menu.py +0 -0
  289. {usage_cli-0.34.14 → usage_cli-0.34.15}/wintray/prefs.py +0 -0
  290. {usage_cli-0.34.14 → usage_cli-0.34.15}/wintray/quota_label.py +0 -0
  291. {usage_cli-0.34.14 → usage_cli-0.34.15}/wintray/taskbar_menu.py +0 -0
  292. {usage_cli-0.34.14 → usage_cli-0.34.15}/wintray/taskbar_obstacles.py +0 -0
  293. {usage_cli-0.34.14 → usage_cli-0.34.15}/wintray/taskbar_overlay.py +0 -0
  294. {usage_cli-0.34.14 → usage_cli-0.34.15}/wintray/watch.py +0 -0
  295. {usage_cli-0.34.14 → usage_cli-0.34.15}/wintray/whats_new.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usage-cli
3
- Version: 0.34.14
3
+ Version: 0.34.15
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
@@ -34,6 +34,7 @@ from typing import Any
34
34
  from urllib.error import HTTPError, URLError
35
35
  from urllib.request import Request, urlopen
36
36
 
37
+ from loaders.cache_quarantine import quarantine
37
38
  from usage_common.cli_lookup import cli_env, resolve_cli
38
39
  from usage_common.network import google_auth_reachable
39
40
  from usage_common.subprocess_utils import hidden_console_kwargs
@@ -86,6 +87,9 @@ _cli_retry_after_monotonic = 0.0
86
87
  # When each group last appeared in a real API response; in memory only, reset on restart.
87
88
  _group_seen_at: dict[str, datetime] = {}
88
89
  _MISSING_GROUP_GRACE_MINUTES = 30
90
+ # Models omit the period; keep known weekly resets weekly near expiry.
91
+ # In memory only: after restart, unseen resets use the time heuristic again.
92
+ _weekly_reset_times: set[str] = set()
89
93
 
90
94
 
91
95
  def _user_agent() -> str:
@@ -527,6 +531,7 @@ def _post_json(url: str, access_token: str, body: dict[str, object], timeout: fl
527
531
  except HTTPError as exc:
528
532
  _handle_http_error(exc)
529
533
  if exc.code == 401:
534
+ _token_cache.clear()
530
535
  _mark_auth_expired()
531
536
  return None
532
537
  except (URLError, OSError, ValueError):
@@ -637,7 +642,13 @@ def _gemini_group_from_models(raw: object) -> AgyQuotaGroup | None:
637
642
  window = _bucket_to_window(bucket)
638
643
  reset = _bucket_reset_time(bucket)
639
644
  assert reset is not None
640
- session = _seconds_until(reset) <= 5 * 3600 + 5 * 60
645
+ _weekly_reset_times.difference_update(
646
+ [seen for seen in _weekly_reset_times if _seconds_until(seen) <= 0]
647
+ )
648
+ session = reset not in _weekly_reset_times and _seconds_until(reset) <= 5 * 3600 + 5 * 60
649
+ # A full bucket's reset time slides with every response, so only a spent one is remembered.
650
+ if not session and window is not None and window.remaining_percent < 100.0:
651
+ _weekly_reset_times.add(reset)
641
652
  template = _assemble_groups({"gemini": {}})[0]
642
653
  return AgyQuotaGroup(
643
654
  name=template.name,
@@ -825,7 +836,14 @@ def _read_cache() -> AgyQuotaResult | None:
825
836
  try:
826
837
  with CACHE_PATH.open(encoding="utf-8") as cache_file:
827
838
  payload: object = json.load(cache_file)
828
- except (OSError, json.JSONDecodeError):
839
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
840
+ quarantine(
841
+ CACHE_PATH, "decode-error" if isinstance(exc, UnicodeDecodeError) else "json-error"
842
+ )
843
+ with suppress(OSError):
844
+ CACHE_PATH.unlink()
845
+ return None
846
+ except OSError:
829
847
  return None
830
848
  return _result_from_payload(payload)
831
849
 
@@ -59,6 +59,7 @@ class AgyQuotaProjection:
59
59
  five_hour: AgyQuotaWindow | None
60
60
  group_switchable: bool = False
61
61
  weekly_only: bool = False
62
+ gemini_five_hour: AgyQuotaWindow | None = None
62
63
 
63
64
 
64
65
  @dataclass(frozen=True, slots=True)
@@ -162,6 +163,10 @@ def project_quota(
162
163
  weekly=weekly,
163
164
  stale=stale,
164
165
  five_hour=selected.five_hour,
166
+ gemini_five_hour=next(
167
+ (group.five_hour for group in quota.groups if _matches_group(group.name, "gemini")),
168
+ None,
169
+ ),
165
170
  )
166
171
 
167
172
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usage-cli"
3
- version = "0.34.14"
3
+ version = "0.34.15"
4
4
  description = "usage — 在 macOS menu bar 與 Windows 系統匣顯示 Claude Code、Codex、Antigravity 用量的小工具(也提供終端機 TUI)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -118,7 +118,7 @@ def maybe_ping(result: AgyRefreshResult, mock: bool) -> None:
118
118
  if not enabled:
119
119
  return
120
120
  projection = result.projection
121
- five_hour = projection.five_hour if projection is not None else None
121
+ five_hour = projection.gemini_five_hour if projection is not None else None
122
122
  now = time.time()
123
123
  last_ping_at = _load_last_ping()
124
124
  state = keeper_outcome.read_state(AGY_WINDOW_KEEPER_STATE_PATH)
@@ -21,7 +21,7 @@ from urllib.request import Request
21
21
 
22
22
  import pytest
23
23
 
24
- from loaders import agy_quota_probe
24
+ from loaders import agy_quota_probe, cache_quarantine
25
25
  from loaders.agy_quota_probe import (
26
26
  AgyQuotaGroup,
27
27
  AgyQuotaResult,
@@ -111,12 +111,14 @@ def _reset_token_cache(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
111
111
  agy_quota_probe._rate_limit_until_monotonic = 0.0
112
112
  agy_quota_probe._cli_retry_after_monotonic = 0.0
113
113
  agy_quota_probe._group_seen_at.clear()
114
+ agy_quota_probe._weekly_reset_times.clear()
114
115
  monkeypatch.setattr(agy_quota_probe, "_read_macos_credential", lambda: None)
115
116
  yield
116
117
  agy_quota_probe._token_cache.clear()
117
118
  agy_quota_probe._rate_limit_until_monotonic = 0.0
118
119
  agy_quota_probe._cli_retry_after_monotonic = 0.0
119
120
  agy_quota_probe._group_seen_at.clear()
121
+ agy_quota_probe._weekly_reset_times.clear()
120
122
 
121
123
 
122
124
  # --- find_agy (unchanged behavior) -----------------------------------------
@@ -683,6 +685,36 @@ def test_load_quota_returns_fresh_cache_without_probing(
683
685
  assert agy_quota_probe.load_quota() == cached
684
686
 
685
687
 
688
+ @pytest.mark.parametrize(
689
+ "contents,reason", [(b"\xff", "decode-error"), (b"{", "json-error"), (None, None)]
690
+ )
691
+ def test_load_quota_probes_after_corrupt_or_missing_cache(
692
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, contents: bytes | None, reason: str | None
693
+ ) -> None:
694
+ cache_path = tmp_path / "agy_quota_cache.json"
695
+ quarantine_dir = tmp_path / "quarantine"
696
+ monkeypatch.setattr(agy_quota_probe, "CACHE_PATH", cache_path)
697
+ monkeypatch.setattr(cache_quarantine, "QUARANTINE_DIR", quarantine_dir)
698
+ if contents is not None:
699
+ cache_path.write_bytes(contents)
700
+ calls: list[tuple[Path, str]] = []
701
+ quarantine = cache_quarantine.quarantine
702
+
703
+ def record_quarantine(path: Path, why: str) -> None:
704
+ calls.append((path, why))
705
+ quarantine(path, why)
706
+
707
+ monkeypatch.setattr(agy_quota_probe, "quarantine", record_quarantine)
708
+ fresh = _result(datetime.now(UTC))
709
+ monkeypatch.setattr(agy_quota_probe, "probe_quota", lambda: fresh)
710
+
711
+ assert agy_quota_probe.load_quota() == fresh
712
+ assert calls == ([(cache_path, reason)] if reason is not None else [])
713
+ backups = list(quarantine_dir.glob("*.bak"))
714
+ assert [backup.read_bytes() for backup in backups] == ([contents] if contents else [])
715
+ assert agy_quota_probe._read_cache() == fresh
716
+
717
+
686
718
  def test_load_quota_probes_when_cache_is_stale(
687
719
  monkeypatch: pytest.MonkeyPatch,
688
720
  tmp_path: Path,
@@ -748,6 +780,24 @@ def test_auth_expiration_marker(
748
780
  assert agy_quota_probe.auth_expired() is expired
749
781
 
750
782
 
783
+ def test_quota_401_clears_cached_token(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
784
+ token_path = tmp_path / "antigravity-oauth-token"
785
+ _write_token(token_path, access_token="new-token")
786
+ monkeypatch.setattr(agy_quota_probe, "_TOKEN_PATH", token_path)
787
+ agy_quota_probe._token_cache.update(
788
+ access_token="rejected-token", expires_monotonic=time.monotonic() + 3600
789
+ )
790
+ urlopen, visited = _build_http_error_urlopen(401)
791
+ monkeypatch.setattr(agy_quota_probe, "urlopen", urlopen)
792
+
793
+ token = agy_quota_probe._resolve_access_token(1)
794
+ assert token == "rejected-token"
795
+ assert agy_quota_probe._post_json(_QUOTA_URL, token, {}, 1) is None
796
+ assert visited == [(_QUOTA_URL, "Bearer rejected-token")]
797
+ assert agy_quota_probe._token_cache == {}
798
+ assert agy_quota_probe._resolve_access_token(1) == "new-token"
799
+
800
+
751
801
  def test_successful_probe_clears_expiration_marker(monkeypatch: pytest.MonkeyPatch) -> None:
752
802
  result = AgyQuotaResult(groups=[], fetched_at=datetime.now(UTC).isoformat())
753
803
  agy_quota_probe.AUTH_EXPIRED_PATH.touch()
@@ -916,6 +966,23 @@ def test_probe_models_fallback_selects_lowest_gemini(
916
966
  ]
917
967
 
918
968
 
969
+ def test_models_fallback_keeps_weekly_reset_near_expiry(monkeypatch: pytest.MonkeyPatch) -> None:
970
+ reset = (datetime.now(UTC) + timedelta(hours=6)).isoformat()
971
+ raw = {
972
+ "models": {"gemini-flash": {"quotaInfo": {"remainingFraction": 0.5, "resetTime": reset}}}
973
+ }
974
+ seconds_left = 6 * 3600
975
+ monkeypatch.setattr(agy_quota_probe, "_seconds_until", lambda _: seconds_left)
976
+
977
+ first = agy_quota_probe._gemini_group_from_models(raw)
978
+ assert first is not None and first.weekly is not None and first.five_hour is None
979
+
980
+ seconds_left = 5 * 3600 + 5 * 60
981
+ refreshed = agy_quota_probe._gemini_group_from_models(raw)
982
+ assert refreshed is not None and refreshed.weekly is not None
983
+ assert refreshed.five_hour is None
984
+
985
+
919
986
  @pytest.mark.parametrize(
920
987
  "models",
921
988
  [None, {"models": {}}, {"models": {"claude": {"quotaInfo": {"resetTime": _FUTURE_RESET}}}}],
@@ -8,13 +8,14 @@ import shutil
8
8
  import subprocess
9
9
  import sys
10
10
  import threading
11
+ from datetime import UTC, datetime
11
12
  from pathlib import Path
12
13
  from typing import Any, cast
13
14
 
14
15
  import pytest
15
16
 
16
17
  import quota.agy_window_keeper as agy_window_keeper
17
- from loaders.agy_quota_probe import AgyQuotaWindow
18
+ from loaders.agy_quota_probe import AgyQuotaGroup, AgyQuotaResult, AgyQuotaWindow
18
19
  from menubar import agy as menubar_agy
19
20
  from menubar.state import AgyStaleState, QuotaRowState
20
21
  from quota import keeper_outcome
@@ -158,6 +159,7 @@ def _refresh_result(
158
159
  weekly=row,
159
160
  stale=stale,
160
161
  five_hour=AgyQuotaWindow(remaining, None, resets_in_minutes),
162
+ gemini_five_hour=AgyQuotaWindow(remaining, None, resets_in_minutes),
161
163
  )
162
164
  return menubar_agy.AgyRefreshResult(projection=projection, hide_agy=False)
163
165
 
@@ -197,6 +199,38 @@ def test_maybe_ping_fires_and_stamps_dispatch(
197
199
  assert _SyncThread.instances[0].daemon is True
198
200
 
199
201
 
202
+ @pytest.mark.parametrize("preference", ["claude_gpt", "argon"])
203
+ @pytest.mark.parametrize("gemini_remaining", [None, 45.0, 100.0])
204
+ def test_maybe_ping_uses_gemini_window_regardless_of_display(
205
+ monkeypatch: pytest.MonkeyPatch,
206
+ isolated_state: Path,
207
+ preference: str,
208
+ gemini_remaining: float | None,
209
+ ) -> None:
210
+ calls = _arm_ping(monkeypatch)
211
+ displayed = AgyQuotaWindow(100.0 if gemini_remaining != 100.0 else 45.0, None, 120)
212
+ groups = [
213
+ AgyQuotaGroup("CLAUDE AND GPT MODELS", [], None, displayed),
214
+ AgyQuotaGroup("GEMINI 4 ARGON", [], displayed, None),
215
+ ]
216
+ if gemini_remaining is not None:
217
+ groups.insert(
218
+ 0,
219
+ AgyQuotaGroup("GEMINI MODELS", [], None, AgyQuotaWindow(gemini_remaining, None, 120)),
220
+ )
221
+ quota = AgyQuotaResult(groups, datetime.now(UTC).isoformat())
222
+ projection = menubar_agy.project_quota(quota, "en", group_preference=preference)
223
+ assert projection is not None
224
+ assert projection.group_name == groups[-1 if preference == "argon" else -2].name
225
+ assert projection.five_hour == (None if preference == "argon" else displayed)
226
+
227
+ agy_window_keeper.maybe_ping(
228
+ menubar_agy.AgyRefreshResult(projection=projection, hide_agy=False), mock=False
229
+ )
230
+
231
+ assert calls == (["/fake/agy"] if gemini_remaining == 100.0 else [])
232
+
233
+
200
234
  def test_maybe_ping_disabled_has_no_side_effects(
201
235
  monkeypatch: pytest.MonkeyPatch, isolated_state: Path
202
236
  ) -> None:
@@ -252,7 +252,7 @@ def test_other_keepers_retry_once_without_active_window(
252
252
  else:
253
253
  monkeypatch.setattr(module, "_agy_window_keeper_enabled", lambda: True)
254
254
  window = SimpleNamespace(remaining_percent=100)
255
- result = SimpleNamespace(projection=SimpleNamespace(five_hour=window, stale=None))
255
+ result = SimpleNamespace(projection=SimpleNamespace(gemini_five_hour=window, stale=None))
256
256
 
257
257
  def dispatch() -> None:
258
258
  module.maybe_ping(result, False)
@@ -387,7 +387,9 @@ def test_warning_across_windows(
387
387
  )
388
388
  if tool == "codex":
389
389
  monkeypatch.setattr(module, "load_rate_limits", lambda: limits)
390
- projection = SimpleNamespace(five_hour=SimpleNamespace(remaining_percent=100), stale=None)
390
+ projection = SimpleNamespace(
391
+ gemini_five_hour=SimpleNamespace(remaining_percent=100), stale=None
392
+ )
391
393
  clock = [1000.0]
392
394
  monkeypatch.setattr(time, "time", lambda: clock[0])
393
395
  result = [subprocess.CompletedProcess([], 2, "", "login")]
@@ -0,0 +1,97 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+
4
+ from __future__ import annotations
5
+
6
+ import socket
7
+ import threading
8
+ import time
9
+ import urllib.request
10
+
11
+ import pytest
12
+
13
+ from usage_common import network
14
+
15
+
16
+ @pytest.fixture(autouse=True)
17
+ def _no_proxy(monkeypatch: pytest.MonkeyPatch) -> None:
18
+ monkeypatch.setattr(urllib.request, "getproxies", dict)
19
+
20
+
21
+ class _Connection:
22
+ def __enter__(self) -> _Connection:
23
+ return self
24
+
25
+ def __exit__(self, *exc: object) -> None:
26
+ return None
27
+
28
+
29
+ def test_reachable_when_connection_opens(monkeypatch: pytest.MonkeyPatch) -> None:
30
+ seen: list[tuple[tuple[str, int], float]] = []
31
+
32
+ def connect(address: tuple[str, int], timeout: float) -> _Connection:
33
+ seen.append((address, timeout))
34
+ return _Connection()
35
+
36
+ monkeypatch.setattr(socket, "create_connection", connect)
37
+
38
+ assert network.google_auth_reachable(timeout=1.5) is True
39
+ assert seen == [(("oauth2.googleapis.com", 443), 1.5)]
40
+
41
+
42
+ @pytest.mark.parametrize("error", [socket.gaierror("no dns"), TimeoutError(), OSError("down")])
43
+ def test_unreachable_when_connection_fails(monkeypatch: pytest.MonkeyPatch, error: OSError) -> None:
44
+ def connect(address: tuple[str, int], timeout: float) -> _Connection:
45
+ raise error
46
+
47
+ monkeypatch.setattr(socket, "create_connection", connect)
48
+
49
+ assert network.google_auth_reachable() is False
50
+
51
+
52
+ def test_configured_proxy_counts_as_reachable(monkeypatch: pytest.MonkeyPatch) -> None:
53
+ def connect(address: tuple[str, int], timeout: float) -> _Connection:
54
+ raise AssertionError("a proxy-only network blocks the direct probe")
55
+
56
+ monkeypatch.setattr(urllib.request, "getproxies", lambda: {"https": "http://proxy:8080"})
57
+ monkeypatch.setattr(socket, "create_connection", connect)
58
+
59
+ assert network.google_auth_reachable() is True
60
+
61
+
62
+ def test_hung_dns_returns_within_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
63
+ entered = threading.Event()
64
+ release = threading.Event()
65
+ finished = threading.Event()
66
+
67
+ def resolve(*args: object, **kwargs: object) -> list[object]:
68
+ try:
69
+ entered.set()
70
+ release.wait()
71
+ raise socket.gaierror("no dns")
72
+ finally:
73
+ finished.set()
74
+
75
+ monkeypatch.setattr(socket, "getaddrinfo", resolve)
76
+ start = time.monotonic()
77
+ try:
78
+ assert network.google_auth_reachable(timeout=0.05) is False
79
+ assert entered.is_set()
80
+ assert time.monotonic() - start < 0.5
81
+ finally:
82
+ release.set()
83
+ assert finished.wait(0.5)
84
+
85
+
86
+ def test_no_proxy_alone_keeps_the_probe(monkeypatch: pytest.MonkeyPatch) -> None:
87
+ seen: list[tuple[str, int]] = []
88
+
89
+ def connect(address: tuple[str, int], timeout: float) -> _Connection:
90
+ seen.append(address)
91
+ raise OSError("down")
92
+
93
+ monkeypatch.setattr(urllib.request, "getproxies", lambda: {"no": "localhost"})
94
+ monkeypatch.setattr(socket, "create_connection", connect)
95
+
96
+ assert network.google_auth_reachable() is False
97
+ assert seen == [("oauth2.googleapis.com", 443)]
@@ -1095,8 +1095,9 @@ def test_status_payload_antigravity_cache(monkeypatch: pytest.MonkeyPatch, age:
1095
1095
  def test_status_antigravity_unreadable_cache(content: str | None) -> None:
1096
1096
  if content is not None:
1097
1097
  agy_quota_probe.CACHE_PATH.write_bytes(content.encode("latin-1"))
1098
+ # A corrupt cache is quarantined and dropped, so every case reads as no data.
1098
1099
  assert status_payload._status_antigravity(1_000_000) == status_payload._status_agent(
1099
- None, 1_000_000, "error" if content == "\xff" else "no_data"
1100
+ None, 1_000_000, "no_data"
1100
1101
  )
1101
1102
 
1102
1103
 
@@ -3573,6 +3573,7 @@ def test_windows_auto_check_schedule_finishes(
3573
3573
 
3574
3574
  def test_windows_poll_loop_rechecks_each_hour(monkeypatch: pytest.MonkeyPatch) -> None:
3575
3575
  controller = wintray._WindowsTrayController(mock=True, interval=60)
3576
+ controller.visible = True
3576
3577
  now = [0.0]
3577
3578
  ticks = iter([100.0, 3699.0, 3700.0])
3578
3579
  refreshes: list[float] = []
@@ -4113,12 +4114,85 @@ def test_hidden_poll_interval_with_quota_countdown(
4113
4114
  waits: list[float] = []
4114
4115
 
4115
4116
  def wait(interval: float) -> bool:
4117
+ if controller.stopping.is_set():
4118
+ return True
4116
4119
  waits.append(interval)
4117
- return True
4120
+ return False
4121
+
4122
+ def refresh() -> None:
4123
+ controller.stopping.set()
4118
4124
 
4119
4125
  monkeypatch.setattr(controller.stopping, "wait", wait)
4126
+ monkeypatch.setattr(controller, "refresh", refresh)
4127
+ monkeypatch.setattr(controller, "_clear_stale_update_cache", lambda: None)
4120
4128
  monkeypatch.setattr("wintray.app.time.time", lambda: 1000.0)
4121
4129
  controller._poll_loop()
4122
4130
 
4123
- assert waits == [expected_wait]
4131
+ assert sum(waits) == expected_wait
4124
4132
  assert controller._poll_thread is None
4133
+
4134
+
4135
+ def test_hidden_poll_refreshes_once_after_countdown_ends(
4136
+ monkeypatch: pytest.MonkeyPatch,
4137
+ ) -> None:
4138
+ controller = wintray._WindowsTrayController(mock=True, interval=60)
4139
+ controller.visible = False
4140
+ controller.tray_provider = "claude"
4141
+ controller.quota_label_enabled = True
4142
+ state = _state()
4143
+ full = replace(state.claude_weekly, percent=100, resets_at=11500.0)
4144
+ controller.latest_state = replace(state, claude_weekly=full)
4145
+ waits: list[float] = []
4146
+
4147
+ def wait(interval: float) -> bool:
4148
+ if controller.stopping.is_set():
4149
+ return True
4150
+ waits.append(interval)
4151
+ if len(waits) == 1:
4152
+ # The quota resets mid-wait, so the countdown is gone on the next wake.
4153
+ controller.latest_state = state
4154
+ return False
4155
+
4156
+ def refresh() -> None:
4157
+ controller.stopping.set()
4158
+
4159
+ monkeypatch.setattr(controller.stopping, "wait", wait)
4160
+ monkeypatch.setattr(controller, "refresh", refresh)
4161
+ monkeypatch.setattr(controller, "_clear_stale_update_cache", lambda: None)
4162
+ monkeypatch.setattr("wintray.app.time.time", lambda: 1000.0)
4163
+ controller._poll_loop()
4164
+
4165
+ assert waits == [60]
4166
+
4167
+
4168
+ def test_hidden_poll_refreshes_soon_after_countdown_starts(
4169
+ monkeypatch: pytest.MonkeyPatch,
4170
+ ) -> None:
4171
+ controller = wintray._WindowsTrayController(mock=True, interval=60)
4172
+ controller.visible = False
4173
+ controller.tray_provider = "claude"
4174
+ controller.quota_label_enabled = True
4175
+ state = _state()
4176
+ controller.latest_state = state
4177
+ waits: list[float] = []
4178
+
4179
+ def wait(interval: float) -> bool:
4180
+ if controller.stopping.is_set():
4181
+ return True
4182
+ waits.append(interval)
4183
+ if len(waits) == 1:
4184
+ # A file event fills the weekly quota while the hidden loop is waiting.
4185
+ full = replace(state.claude_weekly, percent=100, resets_at=11500.0)
4186
+ controller.latest_state = replace(state, claude_weekly=full)
4187
+ return False
4188
+
4189
+ def refresh() -> None:
4190
+ controller.stopping.set()
4191
+
4192
+ monkeypatch.setattr(controller.stopping, "wait", wait)
4193
+ monkeypatch.setattr(controller, "refresh", refresh)
4194
+ monkeypatch.setattr(controller, "_clear_stale_update_cache", lambda: None)
4195
+ monkeypatch.setattr("wintray.app.time.time", lambda: 1000.0)
4196
+ controller._poll_loop()
4197
+
4198
+ assert waits == [60]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usage-cli
3
- Version: 0.34.14
3
+ Version: 0.34.15
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
@@ -9,6 +9,10 @@
9
9
  from __future__ import annotations
10
10
 
11
11
  import socket
12
+ import threading
13
+ import urllib.request
14
+ from concurrent.futures import Future
15
+ from concurrent.futures import TimeoutError as FutureTimeoutError
12
16
 
13
17
  # The host the Antigravity CLI refreshes its OAuth token against.
14
18
  _GOOGLE_AUTH_HOST = "oauth2.googleapis.com"
@@ -21,9 +25,27 @@ def google_auth_reachable(timeout: float = _PROBE_TIMEOUT_SECONDS) -> bool:
21
25
  Right after a sleep wake the network is often still down. The Antigravity CLI
22
26
  treats a failed token refresh as "not logged in" and opens a browser sign-in
23
27
  page, so callers skip launching it while this returns False.
28
+
29
+ A proxy-only network blocks this direct probe even though the CLI can connect
30
+ through the proxy, so a configured HTTPS proxy counts as reachable. A bare
31
+ ``NO_PROXY`` does not route anything through a proxy and keeps the probe.
24
32
  """
33
+ proxies = urllib.request.getproxies()
34
+ if proxies.get("https") or proxies.get("all"):
35
+ return True
36
+ result: Future[bool] = Future()
37
+
38
+ def probe() -> None:
39
+ try:
40
+ with socket.create_connection((_GOOGLE_AUTH_HOST, 443), timeout=timeout):
41
+ result.set_result(True)
42
+ except OSError:
43
+ result.set_result(False)
44
+ except BaseException as exc:
45
+ result.set_exception(exc)
46
+
47
+ threading.Thread(target=probe, daemon=True).start()
25
48
  try:
26
- with socket.create_connection((_GOOGLE_AUTH_HOST, 443), timeout=timeout):
27
- return True
28
- except OSError:
49
+ return result.result(timeout=timeout)
50
+ except FutureTimeoutError:
29
51
  return False
@@ -1437,12 +1437,18 @@ class _WindowsTrayController:
1437
1437
  def _poll_loop(self) -> None:
1438
1438
  current_thread = threading.current_thread()
1439
1439
  self._poll_thread = current_thread
1440
+ waited = 0.0
1441
+ fast = self._poll_fast()
1440
1442
  try:
1441
- while not self.stopping.wait(
1442
- self.interval
1443
- if self.visible or (self.quota_label_enabled and self._tray_countdown() is not None)
1444
- else max(self.interval, SLOW_POLL_INTERVAL_S)
1445
- ):
1443
+ # Wake every interval even while hidden, so a countdown or panel that
1444
+ # appears mid-wait refreshes promptly instead of after the slow interval.
1445
+ # A countdown that just ended still gets one refresh to clear its label.
1446
+ while not self.stopping.wait(self.interval):
1447
+ waited += self.interval
1448
+ was_fast, fast = fast, self._poll_fast()
1449
+ if not (fast or was_fast) and waited < SLOW_POLL_INTERVAL_S:
1450
+ continue
1451
+ waited = 0.0
1446
1452
  self.refresh()
1447
1453
  self._clear_stale_update_cache()
1448
1454
  if self._auto_check_schedule.try_begin(time.time()):
@@ -1451,6 +1457,9 @@ class _WindowsTrayController:
1451
1457
  if self._poll_thread is current_thread:
1452
1458
  self._poll_thread = None
1453
1459
 
1460
+ def _poll_fast(self) -> bool:
1461
+ return self.visible or (self.quota_label_enabled and self._tray_countdown() is not None)
1462
+
1454
1463
  def _ensure_windows_watcher(self) -> None:
1455
1464
  if self.mock or self.stopping.is_set():
1456
1465
  return
@@ -1,41 +0,0 @@
1
- # SPDX-License-Identifier: AGPL-3.0-only
2
- # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
-
4
- from __future__ import annotations
5
-
6
- import socket
7
-
8
- import pytest
9
-
10
- from usage_common import network
11
-
12
-
13
- class _Connection:
14
- def __enter__(self) -> _Connection:
15
- return self
16
-
17
- def __exit__(self, *exc: object) -> None:
18
- return None
19
-
20
-
21
- def test_reachable_when_connection_opens(monkeypatch: pytest.MonkeyPatch) -> None:
22
- seen: list[tuple[tuple[str, int], float]] = []
23
-
24
- def connect(address: tuple[str, int], timeout: float) -> _Connection:
25
- seen.append((address, timeout))
26
- return _Connection()
27
-
28
- monkeypatch.setattr(socket, "create_connection", connect)
29
-
30
- assert network.google_auth_reachable(timeout=1.5) is True
31
- assert seen == [(("oauth2.googleapis.com", 443), 1.5)]
32
-
33
-
34
- @pytest.mark.parametrize("error", [socket.gaierror("no dns"), TimeoutError(), OSError("down")])
35
- def test_unreachable_when_connection_fails(monkeypatch: pytest.MonkeyPatch, error: OSError) -> None:
36
- def connect(address: tuple[str, int], timeout: float) -> _Connection:
37
- raise error
38
-
39
- monkeypatch.setattr(socket, "create_connection", connect)
40
-
41
- assert network.google_auth_reachable() is False
File without changes
File without changes
File without changes