usage-cli 0.34.9__tar.gz → 0.34.10__tar.gz

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