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.
Files changed (109) hide show
  1. adapters/__init__.py +5 -0
  2. adapters/agy.py +68 -0
  3. adapters/claude.py +215 -0
  4. adapters/codex.py +209 -0
  5. adapters/rate_limits.py +76 -0
  6. adapters/registry.py +17 -0
  7. adapters/types.py +139 -0
  8. agy_disk_cache.py +135 -0
  9. agy_loader.py +416 -0
  10. agy_quota_probe.py +748 -0
  11. agy_window_keeper.py +185 -0
  12. analyzer/__init__.py +5 -0
  13. analyzer/aggregator.py +139 -0
  14. analyzer/blocks.py +80 -0
  15. analyzer/diagnoser.py +638 -0
  16. analyzer/insights.py +277 -0
  17. analyzer/persona_loader.py +199 -0
  18. analyzer/reporter.py +989 -0
  19. analyzer/subscription.py +108 -0
  20. burn_rate.py +75 -0
  21. cache_quarantine.py +50 -0
  22. codex_disk_cache.py +227 -0
  23. codex_events.py +136 -0
  24. codex_fork_replay.py +111 -0
  25. codex_loader.py +1426 -0
  26. codex_paths.py +20 -0
  27. critter_frames.py +26 -0
  28. discussion_bridge.py +1196 -0
  29. discussion_cli.py +844 -0
  30. discussion_session.py +622 -0
  31. discussion_usage.py +13 -0
  32. discussion_window.py +955 -0
  33. disk_cache_common.py +132 -0
  34. disk_cache_lifecycle.py +39 -0
  35. doctor.py +452 -0
  36. fsevents_watch.py +207 -0
  37. history_disk_cache.py +110 -0
  38. history_loader.py +416 -0
  39. i18n.py +88 -0
  40. jsonl_limits.py +17 -0
  41. jsonl_utils.py +40 -0
  42. login_item.py +154 -0
  43. main.py +387 -0
  44. menubar.py +1201 -0
  45. menubar_actions.py +204 -0
  46. menubar_agy.py +193 -0
  47. menubar_chrome.py +156 -0
  48. menubar_menu.py +169 -0
  49. menubar_notify.py +102 -0
  50. menubar_popover.py +233 -0
  51. menubar_prefs.py +118 -0
  52. menubar_refresh.py +285 -0
  53. menubar_state.py +1200 -0
  54. menubar_title.py +157 -0
  55. menubar_update.py +123 -0
  56. panel_window.py +78 -0
  57. panel_window_state.py +159 -0
  58. panels/__init__.py +186 -0
  59. panels/base.py +83 -0
  60. panels/dynamic_height.py +140 -0
  61. panels/payload.py +178 -0
  62. panels/web_panel.py +513 -0
  63. panels/window_drag.py +56 -0
  64. prefs.py +44 -0
  65. pricing.py +452 -0
  66. project_resolver.py +112 -0
  67. service_status.py +383 -0
  68. session_hooks.py +1154 -0
  69. setup_app.py +171 -0
  70. setup_hook.py +1011 -0
  71. statusline_settings.py +160 -0
  72. talent_market_bridge.py +243 -0
  73. time_utils.py +24 -0
  74. tui.py +288 -0
  75. tui_sprite.py +206 -0
  76. ui/__init__.py +5 -0
  77. ui/html_report.py +923 -0
  78. ui/report_scripts.py +251 -0
  79. ui/report_styles.py +370 -0
  80. ui/tables.py +888 -0
  81. update_checker.py +156 -0
  82. update_gate.py +66 -0
  83. update_release_notes.py +49 -0
  84. usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
  85. usage_cli-0.29.32.dist-info/METADATA +223 -0
  86. usage_cli-0.29.32.dist-info/RECORD +109 -0
  87. usage_cli-0.29.32.dist-info/WHEEL +5 -0
  88. usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
  89. usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
  90. usage_cli-0.29.32.dist-info/top_level.txt +80 -0
  91. usage_cli.py +827 -0
  92. usage_client.py +487 -0
  93. usage_diagnosis_snapshot.py +143 -0
  94. usage_dir_sweeper.py +100 -0
  95. usage_lang.py +79 -0
  96. usage_logging.py +75 -0
  97. usage_notifications.py +96 -0
  98. usage_rate.py +97 -0
  99. usage_session_resume.py +913 -0
  100. usage_statusline.py +810 -0
  101. usage_statusline_agy.py +397 -0
  102. usage_statusline_forwarder.py +88 -0
  103. usage_terse_mode.py +223 -0
  104. usage_terse_reminder.py +151 -0
  105. win_login_item.py +53 -0
  106. window_keeper.py +264 -0
  107. windows_watch.py +443 -0
  108. wintray.py +2014 -0
  109. wintray_menu.py +136 -0
tui.py ADDED
@@ -0,0 +1,288 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import math
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from functools import lru_cache
14
+ from pathlib import Path
15
+
16
+ from rich.align import Align
17
+ from rich.console import Console, Group, RenderableType
18
+ from rich.panel import Panel
19
+ from rich.table import Table
20
+ from rich.text import Text
21
+
22
+ from i18n import packaged_resource_path
23
+ from tui_sprite import render_sprite
24
+ from usage_client import PollState, UsageSnapshot
25
+ from usage_lang import detect_lang
26
+
27
+ BG = "#000000"
28
+ PANEL = "#1f1f1e"
29
+ TEXT = "#faf9f5"
30
+ DIM = "#b0aea5"
31
+ ACCENT = "#d97757"
32
+ GREEN = "#788c5d"
33
+ RED = "#c0392b"
34
+ BAR_BG = "#2a2a28"
35
+
36
+ SPINNER_FRAMES = ["·", "✻", "✽", "✶", "✳", "✢"]
37
+ SPINNER_PHASES = [0, 1, 2, 3, 4, 5, 4, 3, 2, 1]
38
+ SPINNER_PHASE_MS = [260, 130, 130, 130, 130, 260, 130, 130, 130, 130]
39
+ LOADING_INTERVAL_MS = 4000
40
+ I18N_PATH = packaged_resource_path("i18n.json", Path(__file__).with_name("i18n.json"))
41
+
42
+
43
+ @lru_cache(maxsize=1)
44
+ def _load_i18n_bundle() -> dict[str, dict[str, str]]:
45
+ data = json.loads(I18N_PATH.read_text(encoding="utf-8"))
46
+ return {
47
+ str(lang): {str(key): str(value) for key, value in values.items()}
48
+ for lang, values in data.items()
49
+ }
50
+
51
+
52
+ def _t(language: str, key: str, **kwargs: object) -> str:
53
+ bundle = _load_i18n_bundle()
54
+ table = bundle.get(language) or bundle["en"]
55
+ template = table.get(key) or bundle["en"].get(key) or key
56
+ return template.format(**kwargs)
57
+
58
+
59
+ def _loading_phrases(language: str) -> list[str]:
60
+ return _t(language, "loading_phrases").split("|")
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class AppViewState:
65
+ language: str = field(default_factory=detect_lang)
66
+ poll_state: PollState = PollState.LOADING
67
+ snapshot: UsageSnapshot | None = None
68
+ message: str = ""
69
+ fatal_message: str | None = None
70
+ started_at: float = field(default_factory=time.monotonic)
71
+ rate_group: int = 0
72
+ codex_credits: tuple[str | None, bool] | None = None
73
+
74
+
75
+ def format_countdown(reset_at: float, language: str, now: float | None = None) -> str:
76
+ current_time = now if now is not None else time.time()
77
+ remaining = max(0, math.ceil(reset_at - current_time))
78
+ days, rem = divmod(remaining, 86400)
79
+ hours, rem = divmod(rem, 3600)
80
+ minutes, seconds = divmod(rem, 60)
81
+
82
+ if days > 0:
83
+ return _t(language, "resets_in_days", days=days, hours=hours)
84
+ if hours > 0:
85
+ return _t(language, "resets_in_hours", hours=hours, minutes=minutes)
86
+ return _t(language, "resets_in_minutes", minutes=minutes, seconds=seconds)
87
+
88
+
89
+ def _bar_style(percent: int) -> str:
90
+ if percent < 50:
91
+ return GREEN
92
+ if percent <= 80:
93
+ return ACCENT
94
+ return RED
95
+
96
+
97
+ def _build_progress_line(percent: int, width: int = 28) -> Text:
98
+ filled = round((percent / 100) * width)
99
+ text = Text()
100
+ text.append("▄" * filled, style=f"bold {_bar_style(percent)}")
101
+ text.append("▄" * max(0, width - filled), style=f"bold {BAR_BG}")
102
+ return text
103
+
104
+
105
+ def _chip(label: str) -> Text:
106
+ return Text(f" {label} ", style=f"bold {TEXT} on {PANEL}")
107
+
108
+
109
+ def _usage_block(
110
+ percent: int,
111
+ label: str,
112
+ reset_at: float,
113
+ now: float,
114
+ language: str,
115
+ ) -> RenderableType:
116
+ row = Table.grid(expand=False, padding=(0, 1))
117
+ row.add_column(width=4)
118
+ row.add_column(width=16)
119
+ row.add_column(width=11)
120
+ row.add_row(
121
+ Text(f"{percent}%", style=f"bold {_bar_style(percent)}"),
122
+ _build_progress_line(percent, width=15),
123
+ _chip(label),
124
+ )
125
+
126
+ countdown = Text.assemble(" ", (format_countdown(reset_at, language, now), DIM))
127
+ return Group(row, countdown)
128
+
129
+
130
+ def _missing_usage_block(label: str, language: str) -> RenderableType:
131
+ row = Table.grid(expand=False, padding=(0, 1))
132
+ row.add_column(width=4)
133
+ row.add_column(width=16)
134
+ row.add_column(width=11)
135
+ row.add_row(
136
+ Text("--", style=f"bold {DIM}"),
137
+ _build_progress_line(0, width=15),
138
+ _chip(label),
139
+ )
140
+ return Group(row, Text(f" {_t(language, 'resets_in_placeholder')}", style=DIM))
141
+
142
+
143
+ def _spinner_frame(now: float, started_at: float) -> str:
144
+ elapsed_ms = int((now - started_at) * 1000)
145
+ total_ms = sum(SPINNER_PHASE_MS)
146
+ phase_ms = elapsed_ms % total_ms
147
+ accumulated = 0
148
+
149
+ for phase_index, duration in enumerate(SPINNER_PHASE_MS):
150
+ accumulated += duration
151
+ if phase_ms < accumulated:
152
+ return SPINNER_FRAMES[SPINNER_PHASES[phase_index]]
153
+ return SPINNER_FRAMES[SPINNER_PHASES[-1]]
154
+
155
+
156
+ def _status_line(state: AppViewState, now: float) -> Text:
157
+ spinner = _spinner_frame(now, state.started_at)
158
+ elapsed_ms = (now - state.started_at) * 1000
159
+ phrases = _loading_phrases(state.language)
160
+ phrase_index = int(elapsed_ms / LOADING_INTERVAL_MS) % len(phrases)
161
+
162
+ color = ACCENT
163
+ phrase = phrases[phrase_index]
164
+
165
+ if state.poll_state == PollState.RATE_LIMITED:
166
+ phrase = _t(state.language, "status_rate_limited")
167
+ color = RED
168
+ elif state.poll_state == PollState.TOKEN_ERROR:
169
+ phrase = _t(state.language, "status_token_unavailable")
170
+ color = ACCENT
171
+ elif state.poll_state in (PollState.CONNECTION_ERROR, PollState.FATAL):
172
+ phrase = _t(state.language, "status_api_offline")
173
+ color = RED
174
+
175
+ return Text.assemble(
176
+ (f"{spinner} ", f"bold {color}"),
177
+ (phrase, f"bold {color}"),
178
+ )
179
+
180
+
181
+ def render_screen(state: AppViewState, frame_index: int) -> Panel:
182
+ now = time.time()
183
+ anim_now = time.monotonic()
184
+ panel_width = min(Console().size.width, 60)
185
+
186
+ # Group label and colors
187
+ groups = [
188
+ _t(state.language, "group_idle"),
189
+ _t(state.language, "group_normal"),
190
+ _t(state.language, "group_active"),
191
+ _t(state.language, "group_heavy"),
192
+ ]
193
+ group_colors = [DIM, TEXT, ACCENT, RED]
194
+ group_label = groups[state.rate_group]
195
+ group_color = group_colors[state.rate_group]
196
+
197
+ # Status dot color
198
+ dot_color = ACCENT
199
+ if state.poll_state == PollState.SUCCESS:
200
+ dot_color = GREEN
201
+ elif state.poll_state in (
202
+ PollState.TOKEN_ERROR,
203
+ PollState.CONNECTION_ERROR,
204
+ PollState.RATE_LIMITED,
205
+ PollState.FATAL,
206
+ ):
207
+ dot_color = RED
208
+
209
+ title_table = Table.grid(expand=True, padding=(0, 0))
210
+ title_table.add_column(width=10)
211
+ title_table.add_column(ratio=1, justify="left")
212
+ title_table.add_column(width=4, justify="right")
213
+
214
+ title_left = Text.assemble(
215
+ (f"[{group_label}]", f"bold {group_color}"),
216
+ (" •", f"bold {dot_color}"),
217
+ )
218
+
219
+ title_table.add_row(
220
+ render_sprite(frame_index),
221
+ Text.assemble((f" {_t(state.language, 'usage_title')} ", f"bold {TEXT}"), title_left),
222
+ Text("🔋"),
223
+ )
224
+
225
+ body_items: list[RenderableType] = []
226
+
227
+ if state.snapshot is None:
228
+ loading_block = Group(
229
+ _usage_block(0, _t(state.language, "current_label"), now + 3600, now, state.language),
230
+ Text(""),
231
+ _usage_block(0, _t(state.language, "weekly_label"), now + 86400, now, state.language),
232
+ )
233
+ body_items.append(loading_block)
234
+ else:
235
+ current_block = (
236
+ _usage_block(
237
+ state.snapshot.current_percent,
238
+ _t(state.language, "current_label"),
239
+ state.snapshot.current_reset_at,
240
+ now,
241
+ state.language,
242
+ )
243
+ if state.snapshot.current_percent is not None
244
+ else _missing_usage_block(_t(state.language, "current_label"), state.language)
245
+ )
246
+ weekly_block = (
247
+ _usage_block(
248
+ state.snapshot.weekly_percent,
249
+ _t(state.language, "weekly_label"),
250
+ state.snapshot.weekly_reset_at,
251
+ now,
252
+ state.language,
253
+ )
254
+ if state.snapshot.weekly_percent is not None
255
+ else _missing_usage_block(_t(state.language, "weekly_label"), state.language)
256
+ )
257
+ body_items.append(
258
+ Group(
259
+ current_block,
260
+ Text(""),
261
+ weekly_block,
262
+ )
263
+ )
264
+
265
+ if state.codex_credits is not None:
266
+ balance, unlimited = state.codex_credits
267
+ text = (
268
+ _t(state.language, "codex_credits_unlimited")
269
+ if unlimited
270
+ else _t(state.language, "codex_credits", balance=balance or "--")
271
+ )
272
+ body_items.append(Text(text, style=DIM))
273
+
274
+ screen = Group(
275
+ title_table,
276
+ Text(""),
277
+ *body_items,
278
+ Text(""),
279
+ Align.center(_status_line(state, anim_now)),
280
+ )
281
+
282
+ return Panel(
283
+ screen,
284
+ border_style=PANEL,
285
+ padding=(1, 2),
286
+ width=panel_width,
287
+ style=f"on {BG}",
288
+ )
tui_sprite.py ADDED
@@ -0,0 +1,206 @@
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 rich.style import Style
10
+ from rich.text import Text
11
+
12
+ ORANGE = "#d97757"
13
+ EYE = "#1A1A1A"
14
+
15
+ Pixel = str | None
16
+ Frame = list[list[Pixel]]
17
+
18
+ TRANSPARENT: Pixel = None
19
+
20
+
21
+ FRAMES: list[Frame] = [
22
+ [
23
+ [
24
+ TRANSPARENT,
25
+ TRANSPARENT,
26
+ ORANGE,
27
+ ORANGE,
28
+ ORANGE,
29
+ ORANGE,
30
+ ORANGE,
31
+ TRANSPARENT,
32
+ TRANSPARENT,
33
+ ],
34
+ [TRANSPARENT, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, TRANSPARENT],
35
+ [ORANGE, ORANGE, EYE, ORANGE, ORANGE, ORANGE, EYE, ORANGE, ORANGE],
36
+ [ORANGE, ORANGE, EYE, ORANGE, ORANGE, ORANGE, EYE, ORANGE, ORANGE],
37
+ [ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE],
38
+ [EYE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE],
39
+ [
40
+ TRANSPARENT,
41
+ ORANGE,
42
+ TRANSPARENT,
43
+ ORANGE,
44
+ TRANSPARENT,
45
+ ORANGE,
46
+ TRANSPARENT,
47
+ ORANGE,
48
+ TRANSPARENT,
49
+ ],
50
+ [
51
+ TRANSPARENT,
52
+ ORANGE,
53
+ TRANSPARENT,
54
+ ORANGE,
55
+ TRANSPARENT,
56
+ ORANGE,
57
+ TRANSPARENT,
58
+ ORANGE,
59
+ TRANSPARENT,
60
+ ],
61
+ ],
62
+ [
63
+ [
64
+ TRANSPARENT,
65
+ TRANSPARENT,
66
+ ORANGE,
67
+ ORANGE,
68
+ ORANGE,
69
+ ORANGE,
70
+ ORANGE,
71
+ TRANSPARENT,
72
+ TRANSPARENT,
73
+ ],
74
+ [TRANSPARENT, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, TRANSPARENT],
75
+ [ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE],
76
+ [ORANGE, ORANGE, EYE, ORANGE, ORANGE, ORANGE, EYE, ORANGE, ORANGE],
77
+ [ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE],
78
+ [EYE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE],
79
+ [
80
+ TRANSPARENT,
81
+ ORANGE,
82
+ TRANSPARENT,
83
+ ORANGE,
84
+ TRANSPARENT,
85
+ ORANGE,
86
+ TRANSPARENT,
87
+ ORANGE,
88
+ TRANSPARENT,
89
+ ],
90
+ [
91
+ TRANSPARENT,
92
+ ORANGE,
93
+ TRANSPARENT,
94
+ ORANGE,
95
+ TRANSPARENT,
96
+ ORANGE,
97
+ TRANSPARENT,
98
+ ORANGE,
99
+ TRANSPARENT,
100
+ ],
101
+ ],
102
+ [
103
+ [
104
+ TRANSPARENT,
105
+ TRANSPARENT,
106
+ ORANGE,
107
+ ORANGE,
108
+ ORANGE,
109
+ ORANGE,
110
+ ORANGE,
111
+ TRANSPARENT,
112
+ TRANSPARENT,
113
+ ],
114
+ [TRANSPARENT, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, TRANSPARENT],
115
+ [ORANGE, ORANGE, EYE, ORANGE, ORANGE, ORANGE, EYE, ORANGE, ORANGE],
116
+ [ORANGE, ORANGE, EYE, ORANGE, ORANGE, ORANGE, EYE, ORANGE, ORANGE],
117
+ [ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE],
118
+ [EYE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE],
119
+ [
120
+ TRANSPARENT,
121
+ TRANSPARENT,
122
+ TRANSPARENT,
123
+ ORANGE,
124
+ TRANSPARENT,
125
+ ORANGE,
126
+ TRANSPARENT,
127
+ ORANGE,
128
+ TRANSPARENT,
129
+ ],
130
+ [
131
+ TRANSPARENT,
132
+ ORANGE,
133
+ TRANSPARENT,
134
+ ORANGE,
135
+ TRANSPARENT,
136
+ ORANGE,
137
+ TRANSPARENT,
138
+ ORANGE,
139
+ TRANSPARENT,
140
+ ],
141
+ ],
142
+ [
143
+ [
144
+ TRANSPARENT,
145
+ TRANSPARENT,
146
+ ORANGE,
147
+ ORANGE,
148
+ ORANGE,
149
+ ORANGE,
150
+ ORANGE,
151
+ TRANSPARENT,
152
+ TRANSPARENT,
153
+ ],
154
+ [TRANSPARENT, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, TRANSPARENT],
155
+ [ORANGE, ORANGE, EYE, ORANGE, ORANGE, ORANGE, EYE, ORANGE, ORANGE],
156
+ [ORANGE, ORANGE, EYE, ORANGE, ORANGE, ORANGE, EYE, ORANGE, ORANGE],
157
+ [ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE],
158
+ [EYE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE, ORANGE],
159
+ [
160
+ TRANSPARENT,
161
+ ORANGE,
162
+ TRANSPARENT,
163
+ ORANGE,
164
+ TRANSPARENT,
165
+ ORANGE,
166
+ TRANSPARENT,
167
+ TRANSPARENT,
168
+ TRANSPARENT,
169
+ ],
170
+ [
171
+ TRANSPARENT,
172
+ ORANGE,
173
+ TRANSPARENT,
174
+ ORANGE,
175
+ TRANSPARENT,
176
+ ORANGE,
177
+ TRANSPARENT,
178
+ ORANGE,
179
+ TRANSPARENT,
180
+ ],
181
+ ],
182
+ ]
183
+
184
+
185
+ def render_sprite(frame_index: int) -> Text:
186
+ frame = FRAMES[frame_index % len(FRAMES)]
187
+ text = Text()
188
+
189
+ for row in range(0, len(frame), 2):
190
+ upper = frame[row]
191
+ lower = frame[row + 1] if row + 1 < len(frame) else [None] * len(upper)
192
+
193
+ for upper_pixel, lower_pixel in zip(upper, lower, strict=True):
194
+ if upper_pixel is None and lower_pixel is None:
195
+ text.append(" ")
196
+ continue
197
+ if upper_pixel is None:
198
+ text.append("▄", style=Style(color=lower_pixel))
199
+ continue
200
+ if lower_pixel is None:
201
+ text.append("▀", style=Style(color=upper_pixel))
202
+ continue
203
+ text.append("▀", style=Style(color=upper_pixel, bgcolor=lower_pixel))
204
+ text.append("\n")
205
+
206
+ return text
ui/__init__.py ADDED
@@ -0,0 +1,5 @@
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.