usage-cli 0.29.32__py3-none-any.whl
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.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- wintray_menu.py +136 -0
menubar_menu.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
from AppKit import NSMakePoint, NSMenu, NSMenuItem
|
|
12
|
+
|
|
13
|
+
import login_item
|
|
14
|
+
from i18n import _t
|
|
15
|
+
from menubar_prefs import (
|
|
16
|
+
_hide_agy_enabled,
|
|
17
|
+
_hide_claude_enabled,
|
|
18
|
+
_hide_codex_enabled,
|
|
19
|
+
_quota_notifications_enabled,
|
|
20
|
+
_window_keeper_enabled,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _SwitchMenuApp(Protocol):
|
|
25
|
+
language: str
|
|
26
|
+
active_panel: Any
|
|
27
|
+
_switch_menu_action_taken: bool
|
|
28
|
+
|
|
29
|
+
def _resync_popover_after_menu(self) -> None: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_menu_item(
|
|
33
|
+
language: str,
|
|
34
|
+
title_key: str,
|
|
35
|
+
selector: str,
|
|
36
|
+
*,
|
|
37
|
+
target: Any,
|
|
38
|
+
represented: Any | None = None,
|
|
39
|
+
state: bool | None = None,
|
|
40
|
+
tooltip_key: str | None = None,
|
|
41
|
+
) -> Any:
|
|
42
|
+
item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
|
|
43
|
+
_t(language, title_key), selector, ""
|
|
44
|
+
)
|
|
45
|
+
item.setTarget_(target)
|
|
46
|
+
if represented is not None:
|
|
47
|
+
item.setRepresentedObject_(represented)
|
|
48
|
+
if state is not None:
|
|
49
|
+
item.setState_(1 if state else 0)
|
|
50
|
+
if tooltip_key is not None:
|
|
51
|
+
item.setToolTip_(_t(language, tooltip_key))
|
|
52
|
+
return item
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_switch_menu(app: _SwitchMenuApp, sender: Any) -> None:
|
|
56
|
+
import panels
|
|
57
|
+
from menubar import _session_resume_enabled, _terse_mode_enabled
|
|
58
|
+
|
|
59
|
+
menu = NSMenu.alloc().initWithTitle_(_t(app.language, "switch_panel"))
|
|
60
|
+
# AI 人才市場 is a feature panel, not a cosmetic skin — it gets its own
|
|
61
|
+
# top-level row instead of hiding inside "面板主題 ▸" next to Matrix/Win95.
|
|
62
|
+
menu.addItem_(
|
|
63
|
+
build_menu_item(
|
|
64
|
+
app.language, "panel_talent_market", "toggleTalentMarket:", target=app,
|
|
65
|
+
represented="talent_market",
|
|
66
|
+
state=app.active_panel.id == "talent_market",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
menu.addItem_(
|
|
70
|
+
build_menu_item(
|
|
71
|
+
app.language, "discussion_window_title", "toggleDiscussion:", target=app
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
menu.addItem_(
|
|
75
|
+
build_menu_item(app.language, "panel_ai_daily", "toggleAiDaily:", target=app)
|
|
76
|
+
)
|
|
77
|
+
menu.addItem_(NSMenuItem.separatorItem())
|
|
78
|
+
# Panel themes live in a submenu so the menu stays short — one "面板主題 ▸"
|
|
79
|
+
# row that expands on demand instead of nine inline rows. talent_market is
|
|
80
|
+
# excluded here since it already has its own top-level row above.
|
|
81
|
+
panel_submenu = NSMenu.alloc().initWithTitle_(_t(app.language, "switch_panel"))
|
|
82
|
+
for panel in panels.all_panels():
|
|
83
|
+
if panel.id == "talent_market":
|
|
84
|
+
continue
|
|
85
|
+
panel_submenu.addItem_(
|
|
86
|
+
build_menu_item(
|
|
87
|
+
app.language, panel.i18n_key, "selectPanel:", target=app,
|
|
88
|
+
represented=panel.id,
|
|
89
|
+
state=panel.id == app.active_panel.id,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
panel_parent = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
|
|
93
|
+
_t(app.language, "switch_panel"), "", ""
|
|
94
|
+
)
|
|
95
|
+
panel_parent.setSubmenu_(panel_submenu)
|
|
96
|
+
menu.addItem_(panel_parent)
|
|
97
|
+
# Provider visibility lives in one "Hide Sections ▸" submenu row, grouped
|
|
98
|
+
# with the panel-themes submenu: both are drill-in rows that shape what
|
|
99
|
+
# the popover shows.
|
|
100
|
+
hide_submenu = NSMenu.alloc().initWithTitle_(_t(app.language, "hide_sections_menu"))
|
|
101
|
+
hide_submenu.addItem_(
|
|
102
|
+
build_menu_item(
|
|
103
|
+
app.language, "claude_name", "toggleHideClaude:", target=app,
|
|
104
|
+
state=_hide_claude_enabled(),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
hide_submenu.addItem_(
|
|
108
|
+
build_menu_item(
|
|
109
|
+
app.language, "codex_name", "toggleHideCodex:", target=app,
|
|
110
|
+
state=_hide_codex_enabled(),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
hide_submenu.addItem_(
|
|
114
|
+
build_menu_item(
|
|
115
|
+
app.language, "agy_name", "toggleHideAgy:", target=app,
|
|
116
|
+
state=_hide_agy_enabled(),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
hide_parent = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
|
|
120
|
+
_t(app.language, "hide_sections_menu"), "", ""
|
|
121
|
+
)
|
|
122
|
+
hide_parent.setSubmenu_(hide_submenu)
|
|
123
|
+
menu.addItem_(hide_parent)
|
|
124
|
+
# Plain on/off switches sit together in the second group.
|
|
125
|
+
menu.addItem_(NSMenuItem.separatorItem())
|
|
126
|
+
menu.addItem_(
|
|
127
|
+
build_menu_item(
|
|
128
|
+
app.language, "launch_at_login", "toggleLaunchAtLogin:", target=app,
|
|
129
|
+
state=login_item.is_enabled(),
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
menu.addItem_(
|
|
133
|
+
build_menu_item(
|
|
134
|
+
app.language, "quota_notifications_menu", "toggleQuotaNotifications:", target=app,
|
|
135
|
+
state=_quota_notifications_enabled(),
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
menu.addItem_(
|
|
139
|
+
build_menu_item(
|
|
140
|
+
app.language, "window_keeper_menu", "toggleWindowKeeper:", target=app,
|
|
141
|
+
state=_window_keeper_enabled(),
|
|
142
|
+
tooltip_key="window_keeper_tooltip",
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
# Project Butler: one toggle that hands last session's progress to the next
|
|
146
|
+
# one. Tooltip carries the full explanation so the menu line stays short.
|
|
147
|
+
# Grouped with the plain switches above it — a separate section here just
|
|
148
|
+
# added a divider with no real category difference.
|
|
149
|
+
menu.addItem_(
|
|
150
|
+
build_menu_item(
|
|
151
|
+
app.language, "project_butler", "toggleSessionResume:", target=app,
|
|
152
|
+
state=_session_resume_enabled(),
|
|
153
|
+
tooltip_key="project_butler_tooltip",
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
menu.addItem_(
|
|
157
|
+
build_menu_item(
|
|
158
|
+
app.language, "terse_mode_menu", "toggleTerseMode:", target=app,
|
|
159
|
+
state=_terse_mode_enabled(),
|
|
160
|
+
tooltip_key="terse_mode_tooltip",
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
app._switch_menu_action_taken = False
|
|
164
|
+
menu.popUpMenuPositioningItem_atLocation_inView_(None, NSMakePoint(0, 0), sender)
|
|
165
|
+
# Dismissing the menu without picking anything used to close the panel:
|
|
166
|
+
# a transient popover was already gone by then, so closing it was just
|
|
167
|
+
# cleanup. The floating window survives the menu, so the same call would
|
|
168
|
+
# read as "cancelling the menu threw my panel away".
|
|
169
|
+
app._resync_popover_after_menu()
|
menubar_notify.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import objc
|
|
12
|
+
|
|
13
|
+
from i18n import _t
|
|
14
|
+
from menubar_state import PopoverState, QuotaRowState
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def user_notification_center() -> tuple[Any, dict[str, int]]:
|
|
18
|
+
from UserNotifications import (
|
|
19
|
+
UNAuthorizationOptionAlert,
|
|
20
|
+
UNAuthorizationOptionBadge,
|
|
21
|
+
UNAuthorizationOptionSound,
|
|
22
|
+
UNUserNotificationCenter,
|
|
23
|
+
)
|
|
24
|
+
register_user_notification_block_metadata()
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
UNUserNotificationCenter.currentNotificationCenter(),
|
|
28
|
+
{
|
|
29
|
+
"alert": int(UNAuthorizationOptionAlert),
|
|
30
|
+
"badge": int(UNAuthorizationOptionBadge),
|
|
31
|
+
"sound": int(UNAuthorizationOptionSound),
|
|
32
|
+
},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def user_notification_classes() -> tuple[Any, Any, Any]:
|
|
37
|
+
register_user_notification_block_metadata()
|
|
38
|
+
from UserNotifications import (
|
|
39
|
+
UNMutableNotificationContent,
|
|
40
|
+
UNNotificationRequest,
|
|
41
|
+
UNNotificationSound,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return UNMutableNotificationContent, UNNotificationRequest, UNNotificationSound
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def register_user_notification_block_metadata() -> None:
|
|
48
|
+
objc.registerMetaDataForSelector(
|
|
49
|
+
b"UNUserNotificationCenter",
|
|
50
|
+
b"requestAuthorizationWithOptions:completionHandler:",
|
|
51
|
+
{
|
|
52
|
+
"arguments": {
|
|
53
|
+
3: {
|
|
54
|
+
"callable": {
|
|
55
|
+
"retval": {"type": b"v"},
|
|
56
|
+
"arguments": {
|
|
57
|
+
0: {"type": b"^v"},
|
|
58
|
+
1: {"type": b"Z"},
|
|
59
|
+
2: {"type": b"@"},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
objc.registerMetaDataForSelector(
|
|
67
|
+
b"UNUserNotificationCenter",
|
|
68
|
+
b"addNotificationRequest:withCompletionHandler:",
|
|
69
|
+
{
|
|
70
|
+
"arguments": {
|
|
71
|
+
3: {
|
|
72
|
+
"callable": {
|
|
73
|
+
"retval": {"type": b"v"},
|
|
74
|
+
"arguments": {
|
|
75
|
+
0: {"type": b"^v"},
|
|
76
|
+
1: {"type": b"@"},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def notification_tool(channel: str) -> str:
|
|
86
|
+
return "Claude" if channel.startswith("claude_") else "Codex"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def notification_scope(language: str, channel: str) -> str:
|
|
90
|
+
if channel.endswith("_session"):
|
|
91
|
+
return _t(language, "session_label")
|
|
92
|
+
return _t(language, "weekly_label")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def notification_row(state: PopoverState, channel: str) -> QuotaRowState:
|
|
96
|
+
rows = {
|
|
97
|
+
"claude_session": state.claude_session,
|
|
98
|
+
"claude_weekly": state.claude_weekly,
|
|
99
|
+
"codex_session": state.codex_session,
|
|
100
|
+
"codex_weekly": state.codex_weekly,
|
|
101
|
+
}
|
|
102
|
+
return rows[channel]
|
menubar_popover.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
# mypy: disable-error-code="import-untyped,misc"
|
|
8
|
+
# PyObjC modules do not ship type stubs, and their base classes resolve to Any in mypy.
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import objc
|
|
14
|
+
from AppKit import (
|
|
15
|
+
NSAnimationContext,
|
|
16
|
+
NSMakeSize,
|
|
17
|
+
NSView,
|
|
18
|
+
NSViewController,
|
|
19
|
+
NSViewHeightSizable,
|
|
20
|
+
NSViewWidthSizable,
|
|
21
|
+
)
|
|
22
|
+
from Quartz import CGColorCreateGenericRGB
|
|
23
|
+
|
|
24
|
+
import panel_window_state
|
|
25
|
+
from menubar_state import PopoverState
|
|
26
|
+
from panels.base import Panel as UsagePanel
|
|
27
|
+
from panels.base import next_panel_eviction_id
|
|
28
|
+
|
|
29
|
+
MAX_CACHED_PANEL_VIEWS = 6
|
|
30
|
+
PANEL_TRANSITION_TIMEOUT_SECONDS = 1.5
|
|
31
|
+
PANEL_TRANSITION_FADE_SECONDS = 0.18
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PopoverViewController(NSViewController):
|
|
35
|
+
content_view = objc.ivar()
|
|
36
|
+
panel = objc.ivar()
|
|
37
|
+
delegate = objc.ivar()
|
|
38
|
+
panel_views = objc.ivar()
|
|
39
|
+
panel_lru = objc.ivar()
|
|
40
|
+
transition_overlays = objc.ivar()
|
|
41
|
+
latest_state = objc.ivar()
|
|
42
|
+
pending_panel_evictions = objc.ivar()
|
|
43
|
+
|
|
44
|
+
def initWithPanel_delegate_(self, panel: UsagePanel, delegate: Any) -> PopoverViewController:
|
|
45
|
+
self = objc.super(PopoverViewController, self).init()
|
|
46
|
+
if self is None:
|
|
47
|
+
return None
|
|
48
|
+
self.panel = panel
|
|
49
|
+
self.delegate = delegate
|
|
50
|
+
self.panel_views = {}
|
|
51
|
+
self.panel_lru = []
|
|
52
|
+
self.transition_overlays = {}
|
|
53
|
+
self.pending_panel_evictions = set()
|
|
54
|
+
self.latest_state = None
|
|
55
|
+
self.content_view = panel.build_view(delegate)
|
|
56
|
+
container = NSView.alloc().initWithFrame_(self.content_view.frame())
|
|
57
|
+
container.setWantsLayer_(True)
|
|
58
|
+
self.setView_(container)
|
|
59
|
+
self.preparePanelView_(self.content_view)
|
|
60
|
+
container.addSubview_(self.content_view)
|
|
61
|
+
# Only cache a real web view; a failed build (ErrorPanelView, no JS
|
|
62
|
+
# bridge) is shown but left uncached so a later switch rebuilds it.
|
|
63
|
+
if hasattr(self.content_view, "evaluateJavaScript_completionHandler_"):
|
|
64
|
+
self.panel_views[panel.id] = self.content_view
|
|
65
|
+
self.panel_lru.append(panel.id)
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def setState_(self, state: PopoverState) -> None:
|
|
69
|
+
self.latest_state = state
|
|
70
|
+
self.view().setFrameSize_(_popover_size(state, self.panel))
|
|
71
|
+
self.syncPanelFrames()
|
|
72
|
+
self.panel.apply_state(self.content_view, state)
|
|
73
|
+
|
|
74
|
+
def switchToPanel_(self, panel: UsagePanel) -> None:
|
|
75
|
+
previous = self.content_view
|
|
76
|
+
self.panel = panel
|
|
77
|
+
content_view = self.panel_views.get(panel.id)
|
|
78
|
+
if content_view is None:
|
|
79
|
+
content_view = panel.build_view(self.delegate)
|
|
80
|
+
content_view.setHidden_(True)
|
|
81
|
+
self.preparePanelView_(content_view)
|
|
82
|
+
self.view().addSubview_(content_view)
|
|
83
|
+
# Only cache a real web view. A failed build returns ErrorPanelView
|
|
84
|
+
# (no JS bridge); caching it would pin the error even after the file
|
|
85
|
+
# recovers, so leave it uncached and rebuild on the next switch.
|
|
86
|
+
if hasattr(content_view, "evaluateJavaScript_completionHandler_"):
|
|
87
|
+
self.panel_views[panel.id] = content_view
|
|
88
|
+
self.beginPanelTransitionForPanelId_view_(panel.id, content_view)
|
|
89
|
+
# Drop the previously shown view if it was an uncached error fallback,
|
|
90
|
+
# so it doesn't linger stacked in the container behind the new panel.
|
|
91
|
+
if (
|
|
92
|
+
previous is not None
|
|
93
|
+
and previous is not content_view
|
|
94
|
+
and previous not in self.panel_views.values()
|
|
95
|
+
):
|
|
96
|
+
if hasattr(previous, "teardown"):
|
|
97
|
+
previous.teardown()
|
|
98
|
+
previous.removeFromSuperview()
|
|
99
|
+
self.content_view = content_view
|
|
100
|
+
if panel.id in self.panel_views:
|
|
101
|
+
self.markPanelUsed_(panel.id)
|
|
102
|
+
for panel_id, view in list(self.panel_views.items()):
|
|
103
|
+
view.setHidden_(panel_id != panel.id)
|
|
104
|
+
content_view.setHidden_(False)
|
|
105
|
+
if self.latest_state is not None:
|
|
106
|
+
self.setState_(self.latest_state)
|
|
107
|
+
self.evictPanelViewsIfNeeded()
|
|
108
|
+
|
|
109
|
+
def currentContentView(self) -> Any:
|
|
110
|
+
return self.content_view
|
|
111
|
+
|
|
112
|
+
def panelDidFirstPaint_(self, view: Any) -> None:
|
|
113
|
+
if view is self.content_view:
|
|
114
|
+
self.endPanelTransitionForPanelView_(view)
|
|
115
|
+
|
|
116
|
+
def beginPanelTransitionForPanelId_view_(self, panel_id: str, view: Any) -> None:
|
|
117
|
+
self.removeTransitionOverlay_(panel_id)
|
|
118
|
+
overlay = NSView.alloc().initWithFrame_(view.bounds())
|
|
119
|
+
overlay.setWantsLayer_(True)
|
|
120
|
+
overlay.setAlphaValue_(1.0)
|
|
121
|
+
overlay.setAutoresizingMask_(int(NSViewWidthSizable) | int(NSViewHeightSizable))
|
|
122
|
+
layer = overlay.layer()
|
|
123
|
+
if layer is not None:
|
|
124
|
+
layer.setBackgroundColor_(
|
|
125
|
+
CGColorCreateGenericRGB(10 / 255, 15 / 255, 20 / 255, 1.0)
|
|
126
|
+
)
|
|
127
|
+
view.addSubview_(overlay)
|
|
128
|
+
self.transition_overlays[panel_id] = overlay
|
|
129
|
+
self.performSelector_withObject_afterDelay_(
|
|
130
|
+
"transitionTimeoutElapsed:",
|
|
131
|
+
overlay,
|
|
132
|
+
PANEL_TRANSITION_TIMEOUT_SECONDS,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def transitionTimeoutElapsed_(self, overlay: Any) -> None:
|
|
136
|
+
# Match by the overlay object itself, not the panel id: if this panel was
|
|
137
|
+
# evicted and rebuilt, a stale timer must not fade the new overlay. A timer
|
|
138
|
+
# whose overlay is already gone from the map simply finds nothing and stops.
|
|
139
|
+
for panel_id, current_overlay in list(self.transition_overlays.items()):
|
|
140
|
+
if current_overlay is overlay:
|
|
141
|
+
self.endPanelTransitionForPanelId_(panel_id)
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
def endPanelTransitionForPanelView_(self, view: Any) -> None:
|
|
145
|
+
for panel_id, panel_view in list(self.panel_views.items()):
|
|
146
|
+
if panel_view is view:
|
|
147
|
+
self.endPanelTransitionForPanelId_(panel_id)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
def endPanelTransitionForPanelId_(self, panel_id: str) -> None:
|
|
151
|
+
overlay = self.transition_overlays.pop(panel_id, None)
|
|
152
|
+
if overlay is None:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
def _fade(context: Any) -> None:
|
|
156
|
+
context.setDuration_(PANEL_TRANSITION_FADE_SECONDS)
|
|
157
|
+
overlay.animator().setAlphaValue_(0.0)
|
|
158
|
+
|
|
159
|
+
def _remove() -> None:
|
|
160
|
+
overlay.removeFromSuperview()
|
|
161
|
+
|
|
162
|
+
NSAnimationContext.runAnimationGroup_completionHandler_(_fade, _remove)
|
|
163
|
+
|
|
164
|
+
def teardown(self) -> None:
|
|
165
|
+
for panel_id, view in list(self.panel_views.items()):
|
|
166
|
+
self.removeTransitionOverlay_(panel_id)
|
|
167
|
+
if hasattr(view, "teardown"):
|
|
168
|
+
view.teardown()
|
|
169
|
+
view.removeFromSuperview()
|
|
170
|
+
self.panel_views.clear()
|
|
171
|
+
self.panel_lru.clear()
|
|
172
|
+
self.content_view = None
|
|
173
|
+
|
|
174
|
+
def preparePanelView_(self, view: Any) -> None:
|
|
175
|
+
view.setFrame_(self.view().bounds() if self.view() is not None else view.frame())
|
|
176
|
+
view.setAutoresizingMask_(int(NSViewWidthSizable) | int(NSViewHeightSizable))
|
|
177
|
+
view.setWantsLayer_(True)
|
|
178
|
+
layer = view.layer()
|
|
179
|
+
if layer is not None:
|
|
180
|
+
layer.setMasksToBounds_(True)
|
|
181
|
+
|
|
182
|
+
def syncPanelFrames(self) -> None:
|
|
183
|
+
bounds = self.view().bounds()
|
|
184
|
+
for view in self.panel_views.values():
|
|
185
|
+
view.setFrame_(bounds)
|
|
186
|
+
|
|
187
|
+
def markPanelUsed_(self, panel_id: str) -> None:
|
|
188
|
+
self.panel_lru = [cached_id for cached_id in self.panel_lru if cached_id != panel_id]
|
|
189
|
+
self.panel_lru.append(panel_id)
|
|
190
|
+
|
|
191
|
+
def evictPanelViewsIfNeeded(self) -> None:
|
|
192
|
+
self._schedulePanelEvictionIfNeeded()
|
|
193
|
+
|
|
194
|
+
def _schedulePanelEvictionIfNeeded(self) -> None:
|
|
195
|
+
if self.panel is None or len(self.panel_views) <= MAX_CACHED_PANEL_VIEWS:
|
|
196
|
+
return
|
|
197
|
+
evict_id = next_panel_eviction_id(
|
|
198
|
+
self.panel_lru,
|
|
199
|
+
self.panel.id,
|
|
200
|
+
self.pending_panel_evictions,
|
|
201
|
+
)
|
|
202
|
+
if evict_id is None:
|
|
203
|
+
return
|
|
204
|
+
self.pending_panel_evictions.add(evict_id)
|
|
205
|
+
self.performSelector_withObject_afterDelay_("evictPanelViewForId:", evict_id, 0.0)
|
|
206
|
+
|
|
207
|
+
def evictPanelViewForId_(self, panel_id: str) -> None:
|
|
208
|
+
self.pending_panel_evictions.discard(panel_id)
|
|
209
|
+
# A user can return to this panel before the next run-loop turn. In
|
|
210
|
+
# that case it remains live; schedule another LRU candidate instead.
|
|
211
|
+
if self.panel is None or panel_id == self.panel.id:
|
|
212
|
+
self._schedulePanelEvictionIfNeeded()
|
|
213
|
+
return
|
|
214
|
+
view = self.panel_views.get(panel_id)
|
|
215
|
+
if view is None:
|
|
216
|
+
self._schedulePanelEvictionIfNeeded()
|
|
217
|
+
return
|
|
218
|
+
self.panel_lru = [cached_id for cached_id in self.panel_lru if cached_id != panel_id]
|
|
219
|
+
self.panel_views.pop(panel_id, None)
|
|
220
|
+
self.removeTransitionOverlay_(panel_id)
|
|
221
|
+
if hasattr(view, "teardown"):
|
|
222
|
+
view.teardown()
|
|
223
|
+
view.removeFromSuperview()
|
|
224
|
+
self._schedulePanelEvictionIfNeeded()
|
|
225
|
+
|
|
226
|
+
def removeTransitionOverlay_(self, panel_id: str) -> None:
|
|
227
|
+
overlay = self.transition_overlays.pop(panel_id, None)
|
|
228
|
+
if overlay is not None:
|
|
229
|
+
overlay.removeFromSuperview()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _popover_size(state: PopoverState, panel: UsagePanel | None = None) -> Any:
|
|
233
|
+
return NSMakeSize(*panel_window_state.resolve_panel_size(state, panel))
|
menubar_prefs.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
|
|
11
|
+
from prefs import _load_preferences, _save_preferences
|
|
12
|
+
|
|
13
|
+
DEFAULT_QUOTA_CARD_ORDER = ("claude", "codex", "agy")
|
|
14
|
+
DEFAULT_PANEL_FLAVOR = "mocha"
|
|
15
|
+
PANEL_FLAVORS = ("latte", "frappe", "macchiato", "mocha")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _resolved_preferences(prefs: Mapping[str, object] | None = None) -> Mapping[str, object]:
|
|
19
|
+
return _load_preferences() if prefs is None else prefs
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _auto_update_check_enabled(prefs: Mapping[str, object] | None = None) -> bool:
|
|
23
|
+
data = _resolved_preferences(prefs)
|
|
24
|
+
return data.get("auto_update_check") is not False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _hide_claude_enabled(prefs: Mapping[str, object] | None = None) -> bool:
|
|
28
|
+
data = _resolved_preferences(prefs)
|
|
29
|
+
return data.get("hide_claude_section") is True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _hide_codex_enabled(prefs: Mapping[str, object] | None = None) -> bool:
|
|
33
|
+
data = _resolved_preferences(prefs)
|
|
34
|
+
return data.get("hide_codex_section") is True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _hide_agy_enabled(prefs: Mapping[str, object] | None = None) -> bool:
|
|
38
|
+
data = _resolved_preferences(prefs)
|
|
39
|
+
return data.get("hide_agy_section") is True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _panel_flavor(prefs: Mapping[str, object] | None = None) -> str:
|
|
43
|
+
data = _resolved_preferences(prefs)
|
|
44
|
+
flavor = _valid_panel_flavor(data.get("panel_flavor"))
|
|
45
|
+
return DEFAULT_PANEL_FLAVOR if flavor is None else flavor
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _save_panel_flavor(flavor: object) -> bool:
|
|
49
|
+
valid_flavor = _valid_panel_flavor(flavor)
|
|
50
|
+
if valid_flavor is None:
|
|
51
|
+
return False
|
|
52
|
+
prefs = _load_preferences()
|
|
53
|
+
prefs["panel_flavor"] = valid_flavor
|
|
54
|
+
_save_preferences(prefs)
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _valid_panel_flavor(value: object) -> str | None:
|
|
59
|
+
if not isinstance(value, str) or value not in PANEL_FLAVORS:
|
|
60
|
+
return None
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _quota_card_order(prefs: Mapping[str, object] | None = None) -> tuple[str, ...]:
|
|
65
|
+
data = _resolved_preferences(prefs)
|
|
66
|
+
order = _valid_quota_card_order(data.get("quota_card_order"))
|
|
67
|
+
return DEFAULT_QUOTA_CARD_ORDER if order is None else order
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _save_quota_card_order(order: object) -> bool:
|
|
71
|
+
valid_order = _valid_quota_card_order(order)
|
|
72
|
+
if valid_order is None:
|
|
73
|
+
return False
|
|
74
|
+
prefs = _load_preferences()
|
|
75
|
+
prefs["quota_card_order"] = list(valid_order)
|
|
76
|
+
_save_preferences(prefs)
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _valid_quota_card_order(value: object) -> tuple[str, ...] | None:
|
|
81
|
+
if not isinstance(value, list) or any(not isinstance(card, str) for card in value):
|
|
82
|
+
return None
|
|
83
|
+
order = tuple(value)
|
|
84
|
+
if len(order) != len(set(order)) or not set(order) <= set(DEFAULT_QUOTA_CARD_ORDER):
|
|
85
|
+
return None
|
|
86
|
+
# A saved order shorter than the current default means a source was added
|
|
87
|
+
# since it was written (e.g. a 4th quota card) — append the newcomers
|
|
88
|
+
# instead of discarding the user's whole preference.
|
|
89
|
+
missing = (card for card in DEFAULT_QUOTA_CARD_ORDER if card not in order)
|
|
90
|
+
return order + tuple(missing)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _quota_notifications_enabled(prefs: Mapping[str, object] | None = None) -> bool:
|
|
94
|
+
data = _resolved_preferences(prefs)
|
|
95
|
+
return data.get("quota_notifications") is not False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _window_keeper_enabled(prefs: Mapping[str, object] | None = None) -> bool:
|
|
99
|
+
# Defaults OFF (opt-in) — opposite polarity from quota_notifications, which
|
|
100
|
+
# is why this is `is True` rather than `is not False`.
|
|
101
|
+
data = _resolved_preferences(prefs)
|
|
102
|
+
return data.get("window_keeper") is True or data.get("agy_window_keeper") is True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _agy_window_keeper_enabled(prefs: Mapping[str, object] | None = None) -> bool:
|
|
106
|
+
return _window_keeper_enabled(prefs)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _quota_notification_thresholds(prefs: Mapping[str, object] | None = None) -> list[float]:
|
|
110
|
+
data = _resolved_preferences(prefs)
|
|
111
|
+
raw = data.get("quota_notification_thresholds")
|
|
112
|
+
if not isinstance(raw, list):
|
|
113
|
+
return [90.0]
|
|
114
|
+
thresholds: list[float] = []
|
|
115
|
+
for value in raw:
|
|
116
|
+
if isinstance(value, int | float) and 0 < float(value) <= 100:
|
|
117
|
+
thresholds.append(float(value))
|
|
118
|
+
return thresholds or [90.0]
|