diff --git a/assets/panels/classic.html b/assets/panels/classic.html
index e486154..50d8357 100644
--- a/assets/panels/classic.html
+++ b/assets/panels/classic.html
@@ -199,6 +199,22 @@
margin-left: auto;
}
+ .card[data-card="codex"] .brand.usage-switch-host h1 {
+ flex: none;
+ overflow: visible;
+ }
+
+ .card[data-card="codex"] .brand.usage-switch-host .codex-stale {
+ flex: 1 1 0;
+ overflow: hidden;
+ }
+
+ .card[data-card="codex"] .brand.usage-switch-host [data-codex-stale-age] {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
.codex-stale-info {
position: relative;
display: inline-flex;
diff --git a/assets/panels/panel_core.js b/assets/panels/panel_core.js
index 84bfd4a..8cc2555 100644
--- a/assets/panels/panel_core.js
+++ b/assets/panels/panel_core.js
@@ -66,6 +66,27 @@
const I18N = {{I18N_BUNDLE}};
const FALLBACK_LANGUAGE = "en";
const root = document.documentElement;
+ const switchHostStyle = document.createElement("style");
+ switchHostStyle.textContent = `
+ [data-card="codex"] .usage-switch-host > h1,
+ [data-card="codex"] .usage-switch-host > .header-copy {
+ flex: 0 0 auto !important;
+ min-width: max-content !important;
+ overflow: visible !important;
+ }
+ [data-card="codex"] .usage-switch-host > [data-codex-stale] {
+ flex: 1 1 0 !important;
+ min-width: 0 !important;
+ overflow: hidden !important;
+ }
+ [data-card="codex"] .usage-switch-host [data-codex-stale-age] {
+ display: block;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ `;
+ document.head.appendChild(switchHostStyle);
let currentLanguage = "en";
let projectRange = "1d";
let latestState = null;
@@ -87,6 +108,7 @@
}
function projectRangeLabel(range) {
+ if (range === "yesterday") return t("project_range_yesterday");
if (range === "7d") return t("project_range_7d");
if (range === "30d") return t("project_range_30d");
if (range === "all") return t("project_range_all");
@@ -99,6 +121,13 @@
const key = node.dataset.i18n;
if (key) node.textContent = t(key);
});
+ document.querySelectorAll("[data-i18n-title]").forEach((node) => {
+ const key = node.dataset.i18nTitle;
+ if (!key) return;
+ const text = t(key);
+ node.title = text;
+ node.setAttribute("aria-label", text);
+ });
const rangeButton = document.querySelector('[data-action="toggle-project-range"]');
if (rangeButton) rangeButton.textContent = projectRangeLabel(projectRange);
}
@@ -262,6 +291,14 @@
});
}
+ function renderPeriodTotal(state) {
+ const total = document.querySelector('[data-footer="today"]');
+ if (!total) return;
+ total.textContent = projectRange === "yesterday"
+ ? (state.footer.yesterday || t("yesterday_text", { cost: "0.00", tokens: "0" }))
+ : (state.footer.today || t("today_text", { cost: "0.00", tokens: "0" }));
+ }
+
@@ -277,7 +314,15 @@
}
function relocateSwitchButton(state) {
+ document.querySelectorAll(".usage-switch-host").forEach((host) => {
+ host.classList.remove("usage-switch-host");
+ });
window.PanelHooks.switchButtonStrategy(state);
+ const button = document.querySelector('[data-action="switch"]');
+ const host = button && button.parentElement;
+ if (host && host.closest('[data-card="codex"]')) {
+ host.classList.add("usage-switch-host");
+ }
}
const QUOTA_CARD_IDS = ["claude", "codex", "agy"];
@@ -315,6 +360,7 @@
latestState = state;
renderProjects(
projectRange === "1d" ? state.projects
+ : projectRange === "yesterday" ? state.projectsYesterday
: projectRange === "7d" ? state.projects7d
: projectRange === "30d" ? state.projects30d
: projectRange === "all" ? state.projectsAll
@@ -323,12 +369,11 @@
renderStatusline(state.statusline || {});
const rate = document.querySelector('[data-footer="rate"]');
const status = document.querySelector('[data-footer="status"]');
- const today = document.querySelector('[data-footer="today"]');
const serviceAlerts = document.querySelector('[data-footer="service-alerts"]');
const install = document.querySelector('[data-action="install"]');
if (rate) rate.textContent = state.footer.rate || t("rate_text", { value: "--" });
if (status) status.textContent = state.footer.status || t("status_text", { value: "--" });
- if (today) today.textContent = state.footer.today || t("today_text", { cost: "0.00", tokens: "0" });
+ renderPeriodTotal(state);
if (install) install.dataset.visible = state.footer.showInstall === true ? "true" : "false";
if (serviceAlerts) {
const alerts = state.footer.serviceAlerts || [];
@@ -346,16 +391,21 @@
const button = event.target.closest("[data-action]");
if (!button) return;
if (button.dataset.action === "toggle-project-range") {
- projectRange = projectRange === "1d" ? "7d" : projectRange === "7d" ? "30d" : projectRange === "30d" ? "all" : "1d";
+ projectRange = projectRange === "1d" ? "yesterday" : projectRange === "yesterday" ? "7d" : projectRange === "7d" ? "30d" : projectRange === "30d" ? "all" : "1d";
button.textContent = projectRangeLabel(projectRange);
if (latestState) {
renderProjects(
projectRange === "1d" ? latestState.projects
+ : projectRange === "yesterday" ? latestState.projectsYesterday
: projectRange === "7d" ? latestState.projects7d
: projectRange === "30d" ? latestState.projects30d
: projectRange === "all" ? latestState.projectsAll
: latestState.projects
);
+ renderPeriodTotal(latestState);
+ if (typeof window.usageRequestContentHeight === "function") {
+ window.usageRequestContentHeight();
+ }
}
return;
}
@@ -421,9 +471,10 @@
cardOrder: ["claude", "codex", "agy"],
hideAgy: true,
projects: [],
+ projectsYesterday: [],
projects7d: [],
projects30d: [],
projectsAll: [],
statusline: {},
- footer: { rate: "Rate: --", status: "Status: Loading", today: "Today: $0.00 (0 tokens)", serviceAlerts: [], showInstall: false }
+ footer: { rate: "Rate: --", status: "Status: Loading", today: "Today: $0.00 (0 tokens)", yesterday: "Yesterday: $0.00 (0 tokens)", serviceAlerts: [], showInstall: false }
});
diff --git a/assets/usage.ico b/assets/usage.ico
new file mode 100644
index 0000000..651b3f0
Binary files /dev/null and b/assets/usage.ico differ
diff --git a/codex_loader.py b/codex_loader.py
index 4c897eb..6710899 100644
--- a/codex_loader.py
+++ b/codex_loader.py
@@ -181,6 +181,7 @@ class CodexRateLimits:
has_credits: bool = False
credit_balance: str | None = None
credits_unlimited: bool = False
+ limit_id: str = ""
def _seed_caches_from_disk() -> None:
@@ -416,11 +417,16 @@ def _load_jsonl_rate_limits(
return None
models = _load_thread_models()
# scan 30 recent sessions because short/interrupted Codex sessions write null rate_limits
+ fallback: CodexRateLimits | None = None
for path in _recent_jsonl_files(jsonl_candidates=jsonl_candidates):
rate_limits = _extract_rate_limits(path, models)
- if rate_limits is not None:
+ if rate_limits is None:
+ continue
+ if rate_limits.limit_id == "codex":
return rate_limits
- return None
+ if fallback is None:
+ fallback = rate_limits
+ return fallback
def _rate_limits_timestamp(rate_limits: CodexRateLimits) -> datetime:
@@ -930,6 +936,7 @@ def _extract_rate_limits(path: Path, models: dict[str, str]) -> CodexRateLimits
session_id = ""
session_model = "unknown"
last_rate_limits: tuple[dict[str, Any], str] | None = None
+ last_general_rate_limits: tuple[dict[str, Any], str] | None = None
try:
with path.open(encoding="utf-8") as file:
for line in file:
@@ -951,9 +958,12 @@ def _extract_rate_limits(path: Path, models: dict[str, str]) -> CodexRateLimits
rate_limits = _as_dict(payload.get("rate_limits"))
if rate_limits:
last_rate_limits = (rate_limits, _as_str(data.get("timestamp")))
+ if rate_limits.get("limit_id") == "codex":
+ last_general_rate_limits = last_rate_limits
except (OSError, UnicodeDecodeError) as exc:
logger.warning("failed to read codex session %s: %s", path, exc)
return None
+ last_rate_limits = last_general_rate_limits or last_rate_limits
if last_rate_limits is None:
return None
rate_limits, updated_at = last_rate_limits
@@ -997,6 +1007,7 @@ def _extract_rate_limits(path: Path, models: dict[str, str]) -> CodexRateLimits
has_credits=credits.get("has_credits") is True,
credit_balance=_as_str(credits.get("balance")) or None,
credits_unlimited=credits.get("unlimited") is True,
+ limit_id=_as_str(rate_limits.get("limit_id")),
)
diff --git a/i18n.json b/i18n.json
index a5397be..b6ea547 100644
--- a/i18n.json
+++ b/i18n.json
@@ -128,6 +128,14 @@
"talent_search_placeholder": "搜尋團隊",
"talent_no_results": "搜尋不到符合的團隊",
"switch_panel": "更換面板",
+ "language_menu": "語言",
+ "language_auto": "自動(依系統)",
+ "language_zh_cn": "简体中文",
+ "language_zh_tw": "繁體中文",
+ "language_en": "English",
+ "language_ja": "日本語",
+ "language_ko": "한국어",
+ "close_to_tray": "關閉到系統匣",
"talent_launch": "啟動",
"talent_back": "返回",
"talent_restore": "修復",
@@ -188,6 +196,7 @@
"service_partial_outage": "部分中斷",
"service_major_outage": "大規模中斷",
"today_text": "今日:${cost} ({tokens} tokens)",
+ "yesterday_text": "昨日:${cost} ({tokens} tokens)",
"percent_used": "{value}% 已用",
"reset_in": "重置 {time}",
"reset_imminent": "即將重置",
@@ -218,6 +227,7 @@
"history_load_error_tooltip": "目前顯示的是上次成功讀到的資料,等問題排除後會自動恢復。",
"projects_title": "專案用量",
"project_range_1d": "今日",
+ "project_range_yesterday": "昨日",
"project_range_7d": "7 日",
"project_range_30d": "月",
"project_range_all": "全部",
@@ -609,6 +619,14 @@
"talent_search_placeholder": "Search teams",
"talent_no_results": "No matching teams found",
"switch_panel": "Switch Panel",
+ "language_menu": "Language",
+ "language_auto": "Auto (System)",
+ "language_zh_cn": "简体中文",
+ "language_zh_tw": "繁體中文",
+ "language_en": "English",
+ "language_ja": "日本語",
+ "language_ko": "한국어",
+ "close_to_tray": "Close to tray",
"talent_launch": "Launch",
"talent_back": "Back",
"talent_restore": "Restore",
@@ -669,6 +687,7 @@
"service_partial_outage": "Partial outage",
"service_major_outage": "Major outage",
"today_text": "Today: ${cost} ({tokens} tokens)",
+ "yesterday_text": "Yesterday: ${cost} ({tokens} tokens)",
"percent_used": "{value}% used",
"reset_in": "Resets in {time}",
"reset_imminent": "Reset imminent",
@@ -699,6 +718,7 @@
"history_load_error_tooltip": "Showing the last successfully loaded data; this will recover automatically once resolved.",
"projects_title": "Project Usage",
"project_range_1d": "Today",
+ "project_range_yesterday": "Yesterday",
"project_range_7d": "7 Days",
"project_range_30d": "Month",
"project_range_all": "All Time",
@@ -1090,6 +1110,14 @@
"talent_search_placeholder": "搜索团队",
"talent_no_results": "搜索不到符合的团队",
"switch_panel": "切换面板",
+ "language_menu": "语言",
+ "language_auto": "自动(跟随系统)",
+ "language_zh_cn": "简体中文",
+ "language_zh_tw": "繁體中文",
+ "language_en": "English",
+ "language_ja": "日本語",
+ "language_ko": "한국어",
+ "close_to_tray": "关闭到托盘",
"talent_launch": "启动",
"talent_back": "返回",
"talent_restore": "修复",
@@ -1150,6 +1178,7 @@
"service_partial_outage": "部分中断",
"service_major_outage": "大规模中断",
"today_text": "今日:${cost} ({tokens} tokens)",
+ "yesterday_text": "昨天:${cost} ({tokens} tokens)",
"percent_used": "{value}% 已用",
"reset_in": "重置 {time}",
"reset_imminent": "即将重置",
@@ -1180,6 +1209,7 @@
"history_load_error_tooltip": "目前显示的是上次成功读到的数据,问题解决后会自动恢复。",
"projects_title": "项目用量",
"project_range_1d": "今日",
+ "project_range_yesterday": "昨天",
"project_range_7d": "7 日",
"project_range_30d": "月",
"project_range_all": "全部",
@@ -1571,6 +1601,14 @@
"talent_search_placeholder": "チームを検索",
"talent_no_results": "該当するチームが見つかりません",
"switch_panel": "パネル切替",
+ "language_menu": "言語",
+ "language_auto": "自動(システム)",
+ "language_zh_cn": "简体中文",
+ "language_zh_tw": "繁體中文",
+ "language_en": "English",
+ "language_ja": "日本語",
+ "language_ko": "한국어",
+ "close_to_tray": "トレイに閉じる",
"talent_launch": "起動",
"talent_back": "戻る",
"talent_restore": "修復",
@@ -1631,6 +1669,7 @@
"service_partial_outage": "一部障害",
"service_major_outage": "大規模障害",
"today_text": "今日: ${cost} ({tokens} tokens)",
+ "yesterday_text": "昨日: ${cost} ({tokens} tokens)",
"percent_used": "{value}% 使用済み",
"reset_in": "{time}後にリセット",
"reset_imminent": "まもなくリセット",
@@ -1661,6 +1700,7 @@
"history_load_error_tooltip": "現在表示中なのは前回正常に読み込めたデータです。問題が解消すると自動的に復旧します。",
"projects_title": "プロジェクト使用量",
"project_range_1d": "今日",
+ "project_range_yesterday": "昨日",
"project_range_7d": "7日間",
"project_range_30d": "月",
"project_range_all": "全期間",
@@ -2052,6 +2092,14 @@
"talent_search_placeholder": "팀 검색",
"talent_no_results": "일치하는 팀을 찾을 수 없습니다",
"switch_panel": "패널 전환",
+ "language_menu": "언어",
+ "language_auto": "자동 (시스템)",
+ "language_zh_cn": "简体中文",
+ "language_zh_tw": "繁體中文",
+ "language_en": "English",
+ "language_ja": "日本語",
+ "language_ko": "한국어",
+ "close_to_tray": "트레이로 닫기",
"talent_launch": "실행",
"talent_back": "뒤로",
"talent_restore": "복원",
@@ -2112,6 +2160,7 @@
"service_partial_outage": "부분 장애",
"service_major_outage": "대규모 장애",
"today_text": "오늘: ${cost} ({tokens} tokens)",
+ "yesterday_text": "어제: ${cost} ({tokens} tokens)",
"percent_used": "{value}% 사용됨",
"reset_in": "{time} 후 초기화",
"reset_imminent": "곧 초기화",
@@ -2142,6 +2191,7 @@
"history_load_error_tooltip": "마지막으로 정상적으로 불러온 데이터를 표시하고 있습니다. 문제가 해결되면 자동으로 복구됩니다.",
"projects_title": "프로젝트 사용량",
"project_range_1d": "오늘",
+ "project_range_yesterday": "어제",
"project_range_7d": "7일",
"project_range_30d": "월",
"project_range_all": "전체",
diff --git a/installer/windows/usage.iss b/installer/windows/usage.iss
new file mode 100644
index 0000000..182d2bf
--- /dev/null
+++ b/installer/windows/usage.iss
@@ -0,0 +1,54 @@
+#ifndef AppVersion
+ #error AppVersion must be supplied by the build script
+#endif
+#ifndef SourceDir
+ #error SourceDir must be supplied by the build script
+#endif
+#ifndef OutputDir
+ #error OutputDir must be supplied by the build script
+#endif
+#ifndef RepoRoot
+ #error RepoRoot must be supplied by the build script
+#endif
+
+[Setup]
+AppId={{5B469EB3-1018-4ACB-B137-E45606C13448}
+AppName=Usage
+AppVersion={#AppVersion}
+AppPublisher=lollapalooza
+AppPublisherURL=https://github.com/aqua5230/usage
+AppSupportURL=https://github.com/aqua5230/usage/issues
+DefaultDirName={localappdata}\Programs\Usage
+DefaultGroupName=Usage
+DisableProgramGroupPage=yes
+PrivilegesRequired=lowest
+ArchitecturesAllowed=x64compatible
+ArchitecturesInstallIn64BitMode=x64compatible
+LicenseFile={#RepoRoot}\LICENSE
+SetupIconFile={#RepoRoot}\assets\usage.ico
+UninstallDisplayIcon={app}\usage.exe
+OutputDir={#OutputDir}
+OutputBaseFilename=UsageSetup-{#AppVersion}
+Compression=lzma2/max
+SolidCompression=yes
+WizardStyle=modern
+CloseApplications=yes
+RestartApplications=no
+
+[Tasks]
+Name: "desktopicon"; Description: "Create a desktop shortcut"
+Name: "autostart"; Description: "Start Usage when I sign in to Windows"
+
+[Files]
+Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
+Source: "{#RepoRoot}\LICENSE"; DestDir: "{app}"; Flags: ignoreversion
+
+[Icons]
+Name: "{group}\Usage"; Filename: "{app}\usage.exe"
+Name: "{autodesktop}\Usage"; Filename: "{app}\usage.exe"; Tasks: desktopicon
+
+[Registry]
+Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "Usage"; ValueData: "{app}\usage.exe"; Flags: uninsdeletevalue; Tasks: autostart
+
+[Run]
+Filename: "{app}\usage.exe"; Description: "Launch Usage"; Flags: nowait postinstall skipifsilent
diff --git a/menubar_refresh.py b/menubar_refresh.py
index 70b10b7..b5efee0 100644
--- a/menubar_refresh.py
+++ b/menubar_refresh.py
@@ -117,10 +117,12 @@ def build_result(app: _RefreshApp, sources: RefreshSources) -> dict[str, Any]:
animation_groups = sources.animation_groups
fallback_state = getattr(app, "latest_state", menubar_state._empty_state(app.language))
project_rows = list(fallback_state.projects)
+ project_rows_yesterday = list(fallback_state.projects_yesterday)
project_rows_7d = list(fallback_state.projects_7d)
project_rows_30d = list(fallback_state.projects_30d)
project_rows_all = list(fallback_state.projects_all)
today_text = fallback_state.today_text
+ yesterday_text = fallback_state.yesterday_text
statusline = fallback_state.statusline
hide_claude = fallback_state.hide_claude
hide_codex = fallback_state.hide_codex
@@ -136,18 +138,23 @@ def build_result(app: _RefreshApp, sources: RefreshSources) -> dict[str, Any]:
started_at = time.monotonic() if sources.debug_timing else 0.0
if app.mock:
project_rows = app._project_rows(hours_back=24, entries=all_entries)
+ project_rows_yesterday = project_rows
project_rows_7d = app._project_rows(hours_back=168, entries=all_entries)
project_rows_30d = app._project_rows(hours_back=720, entries=all_entries)
project_rows_all = app._project_rows(hours_back=0, entries=all_entries)
else:
(
project_rows,
+ project_rows_yesterday,
project_rows_7d,
project_rows_30d,
project_rows_all,
) = menubar_state.project_rows_for_windows(all_entries)
sources.measure("project_rows_windows", started_at)
today_text = menubar_state._today_title(app.mock, app.language, entries=all_entries)
+ yesterday_text = menubar_state._yesterday_title(
+ app.mock, app.language, entries=all_entries
+ )
statusline = menubar_state._statusline_payload(app.language)
hide_claude = _hide_claude_enabled()
hide_codex = _hide_codex_enabled()
@@ -197,6 +204,7 @@ def build_result(app: _RefreshApp, sources: RefreshSources) -> dict[str, Any]:
agy_rows=(agy_projection.session, agy_projection.weekly),
agy_group_name=agy_projection.group_name,
projects=project_rows,
+ projects_yesterday=project_rows_yesterday,
projects_7d=project_rows_7d,
projects_30d=project_rows_30d,
projects_all=project_rows_all,
@@ -204,6 +212,7 @@ def build_result(app: _RefreshApp, sources: RefreshSources) -> dict[str, Any]:
group=group,
burn_rate_trackers=app.burn_rate_trackers,
today_text=today_text,
+ yesterday_text=yesterday_text,
statusline=statusline,
show_install_button=show_install_button,
hide_claude=hide_claude,
@@ -244,10 +253,12 @@ def build_result(app: _RefreshApp, sources: RefreshSources) -> dict[str, Any]:
app._history_load_error_key, app.language
)
state.projects = project_rows
+ state.projects_yesterday = project_rows_yesterday
state.projects_7d = project_rows_7d
state.projects_30d = project_rows_30d
state.projects_all = project_rows_all
state.today_text = today_text
+ state.yesterday_text = yesterday_text
state.statusline = statusline
state.hide_claude = hide_claude
state.hide_codex = hide_codex
diff --git a/menubar_state.py b/menubar_state.py
index 1e5005d..0be778c 100644
--- a/menubar_state.py
+++ b/menubar_state.py
@@ -144,12 +144,14 @@ class PopoverState:
agy_weekly: QuotaRowState
agy_group_name: str
projects: list[tuple[str, int, float | None]]
+ projects_yesterday: list[tuple[str, int, float | None]]
projects_7d: list[tuple[str, int, float | None]]
projects_30d: list[tuple[str, int, float | None]]
projects_all: list[tuple[str, int, float | None]]
rate_text: str
status_text: str
today_text: str
+ yesterday_text: str
statusline: dict[str, object]
service_alerts: tuple[str, ...] = ()
show_install_button: bool = False
@@ -517,6 +519,7 @@ def project_rows_for_windows(
list[tuple[str, int, float | None]],
list[tuple[str, int, float | None]],
list[tuple[str, int, float | None]],
+ list[tuple[str, int, float | None]],
]:
"""Aggregate the four project windows in one pass over the history."""
current_time = datetime.now(UTC) if now is None else now
@@ -530,9 +533,13 @@ def project_rows_for_windows(
tomorrow_start = datetime.combine(
local_today + timedelta(days=1), datetime_time.min, tzinfo=local_tz
).astimezone(UTC)
+ yesterday_start = datetime.combine(
+ local_today - timedelta(days=1), datetime_time.min, tzinfo=local_tz
+ ).astimezone(UTC)
cutoff_7d = current_time - timedelta(hours=168)
cutoff_30d = current_time - timedelta(hours=720)
aggregates_24h: dict[str, list[float]] = {}
+ aggregates_yesterday: dict[str, list[float]] = {}
aggregates_7d: dict[str, list[float]] = {}
aggregates_30d: dict[str, list[float]] = {}
aggregates_all: dict[str, list[float]] = {}
@@ -547,9 +554,12 @@ def project_rows_for_windows(
_add_project_usage(aggregates_7d, entry.project, tokens, cost)
if today_start <= entry.timestamp < tomorrow_start:
_add_project_usage(aggregates_24h, entry.project, tokens, cost)
+ elif yesterday_start <= entry.timestamp < today_start:
+ _add_project_usage(aggregates_yesterday, entry.project, tokens, cost)
return (
_rank_project_rows(aggregates_24h),
+ _rank_project_rows(aggregates_yesterday),
_rank_project_rows(aggregates_7d),
_rank_project_rows(aggregates_30d),
_rank_project_rows(aggregates_all),
@@ -793,6 +803,7 @@ def build_popover_state(
agy_rows: tuple[QuotaRowState, QuotaRowState],
agy_group_name: str,
projects: list[tuple[str, int, float | None]],
+ projects_yesterday: list[tuple[str, int, float | None]],
projects_7d: list[tuple[str, int, float | None]],
projects_30d: list[tuple[str, int, float | None]],
projects_all: list[tuple[str, int, float | None]],
@@ -800,6 +811,7 @@ def build_popover_state(
group: int,
burn_rate_trackers: dict[str, BurnRateTracker],
today_text: str,
+ yesterday_text: str,
statusline: dict[str, object],
show_install_button: bool,
hide_claude: bool,
@@ -909,12 +921,14 @@ def build_popover_state(
agy_weekly=agy_rows[1],
agy_group_name=agy_group_name,
projects=projects,
+ projects_yesterday=projects_yesterday,
projects_7d=projects_7d,
projects_30d=projects_30d,
projects_all=projects_all,
rate_text=_t(language, "rate_text", value=group_name),
status_text=status_text,
today_text=today_text,
+ yesterday_text=yesterday_text,
statusline=statusline,
service_alerts=service_alerts,
show_install_button=show_install_button,
@@ -1079,12 +1093,14 @@ def _empty_state(language: str = "en") -> PopoverState:
agy_weekly=_missing_row(_t(language, "weekly_label"), AGY_COLOR, language),
agy_group_name="",
projects=[],
+ projects_yesterday=[],
projects_7d=[],
projects_30d=[],
projects_all=[],
rate_text=_t(language, "rate_text", value="--"),
status_text=_t(language, "status_text", value=_t(language, "status_loading")),
today_text=_t(language, "today_text", cost="0.00", tokens="0"),
+ yesterday_text=_t(language, "yesterday_text", cost="0.00", tokens="0"),
statusline=_statusline_payload(language),
service_alerts=(),
show_install_button=False,
@@ -1148,3 +1164,34 @@ def _today_title(
return _t(language, "today_text", cost="0.00", tokens="0")
return _t(language, "today_text", cost=f"{total_cost:.2f}", tokens=f"{total_tokens:,}")
+
+
+def _yesterday_title(
+ mock: bool = False,
+ language: str = "en",
+ entries: list[UsageEntry] | None = None,
+) -> str:
+ if mock:
+ return _t(language, "yesterday_text", cost="41.10", tokens="48,200,000")
+
+ try:
+ yesterday = datetime.now().astimezone().date() - timedelta(days=1)
+ all_entries = (
+ entries
+ if entries is not None
+ else list(load_entries(hours_back=48)) + codex_loader.load_entries(hours_back=48)
+ )
+ selected = [entry for entry in all_entries if entry.timestamp.astimezone().date() == yesterday]
+ total_tokens = sum(entry.total_tokens for entry in selected)
+ total_cost = sum(calculate_cost(entry) for entry in selected)
+ except Exception:
+ if os.environ.get("USAGE_DEBUG") == "1":
+ logger.warning("yesterday totals load failed", exc_info=True)
+ return _t(language, "yesterday_text", cost="0.00", tokens="0")
+
+ return _t(
+ language,
+ "yesterday_text",
+ cost=f"{total_cost:.2f}",
+ tokens=f"{total_tokens:,}",
+ )
diff --git a/panels/dynamic_height.py b/panels/dynamic_height.py
index 94a8f8a..0eb4e3e 100644
--- a/panels/dynamic_height.py
+++ b/panels/dynamic_height.py
@@ -12,6 +12,8 @@
(function() {
var applyState = window.usageApplyState;
if (typeof applyState !== "function") return;
+ var scheduled = false;
+ var lastPostedHeight = null;
function naturalContentHeight() {
var wrap = document.querySelector(".wrap");
if (!wrap) return null;
@@ -76,17 +78,48 @@
});
}
}
- window.usageApplyState = function usageApplyStateWithDynamicHeight(state) {
- var result = applyState.apply(this, arguments);
+ function reportContentHeight() {
+ scheduled = false;
var height = naturalContentHeight();
var bridge = window.webkit && window.webkit.messageHandlers
&& window.webkit.messageHandlers.usage;
- if (Number.isFinite(height) && height > 0 && bridge
+ if (Number.isFinite(height) && height > 0 && height !== lastPostedHeight && bridge
&& typeof bridge.postMessage === "function") {
+ lastPostedHeight = height;
bridge.postMessage(JSON.stringify({ action: "content_height", height: height }));
}
+ }
+ function requestContentHeight() {
+ if (scheduled) return;
+ scheduled = true;
+ // Measure after the browser has committed DOM, font, and layout changes.
+ // A second frame catches WebView2's first visible layout without relying
+ // on a user clicking another control.
+ requestAnimationFrame(function() {
+ requestAnimationFrame(reportContentHeight);
+ });
+ }
+ window.usageRequestContentHeight = requestContentHeight;
+ window.usageApplyState = function usageApplyStateWithDynamicHeight(state) {
+ var result = applyState.apply(this, arguments);
+ requestContentHeight();
return result;
};
+ var wrap = document.querySelector(".wrap");
+ if (wrap && typeof MutationObserver === "function") {
+ new MutationObserver(requestContentHeight).observe(wrap, {
+ childList: true,
+ characterData: true,
+ subtree: true
+ });
+ }
+ if (wrap && typeof ResizeObserver === "function") {
+ new ResizeObserver(requestContentHeight).observe(wrap);
+ }
+ if (document.fonts && document.fonts.ready) {
+ document.fonts.ready.then(requestContentHeight);
+ }
+ requestContentHeight();
})();
""".strip()
diff --git a/panels/payload.py b/panels/payload.py
index 3f5caed..bdea633 100644
--- a/panels/payload.py
+++ b/panels/payload.py
@@ -107,7 +107,13 @@ def _state_payload(state: PopoverState) -> dict[str, object]:
if row.title
}
project_payloads = []
- for rows in (state.projects, state.projects_7d, state.projects_30d, state.projects_all):
+ for rows in (
+ state.projects,
+ state.projects_yesterday,
+ state.projects_7d,
+ state.projects_30d,
+ state.projects_all,
+ ):
project_payloads.append(
[
{
@@ -137,9 +143,10 @@ def _state_payload(state: PopoverState) -> dict[str, object]:
"stale": state.agy_stale,
},
"projects": project_payloads[0],
- "projects7d": project_payloads[1],
- "projects30d": project_payloads[2],
- "projectsAll": project_payloads[3],
+ "projectsYesterday": project_payloads[1],
+ "projects7d": project_payloads[2],
+ "projects30d": project_payloads[3],
+ "projectsAll": project_payloads[4],
"hideClaude": state.hide_claude,
"hideCodex": state.hide_codex,
"hideAgy": state.hide_agy,
@@ -151,6 +158,7 @@ def _state_payload(state: PopoverState) -> dict[str, object]:
"rate": state.rate_text,
"status": state.status_text,
"today": state.today_text,
+ "yesterday": state.yesterday_text,
"serviceAlerts": list(state.service_alerts),
"showInstall": state.show_install_button,
},
diff --git a/scripts/build_windows.ps1 b/scripts/build_windows.ps1
index 3872092..3afe143 100644
--- a/scripts/build_windows.ps1
+++ b/scripts/build_windows.ps1
@@ -7,6 +7,7 @@ $OutputDir = Join-Path $DistRoot "usage-windows"
$PyInstallerOutput = Join-Path $DistRoot "usage"
$BuildDir = Join-Path $RepoRoot "build/pyinstaller-windows"
$SpecDir = Join-Path $RepoRoot "build/pyinstaller-spec"
+$IconPath = Join-Path $RepoRoot "assets/usage.ico"
Remove-Item $OutputDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $PyInstallerOutput -Recurse -Force -ErrorAction SilentlyContinue
@@ -20,6 +21,7 @@ try {
--windowed `
--onedir `
--name usage `
+ --icon $IconPath `
--distpath $DistRoot `
--workpath $BuildDir `
--specpath $SpecDir `
diff --git a/scripts/build_windows_installer.ps1 b/scripts/build_windows_installer.ps1
new file mode 100644
index 0000000..6bf6ed9
--- /dev/null
+++ b/scripts/build_windows_installer.ps1
@@ -0,0 +1,43 @@
+$ErrorActionPreference = "Stop"
+Set-StrictMode -Version Latest
+
+$RepoRoot = Split-Path -Parent $PSScriptRoot
+$SourceDir = Join-Path $RepoRoot "dist/usage-windows"
+$OutputDir = Join-Path $RepoRoot "dist/installer"
+$InstallerScript = Join-Path $RepoRoot "installer/windows/usage.iss"
+$ProjectFile = Join-Path $RepoRoot "pyproject.toml"
+
+& (Join-Path $PSScriptRoot "build_windows.ps1")
+
+$VersionLine = Select-String -LiteralPath $ProjectFile -Pattern '^version\s*=\s*"([^"]+)"$'
+if ($null -eq $VersionLine) {
+ throw "Could not read the project version from $ProjectFile"
+}
+$AppVersion = $VersionLine.Matches[0].Groups[1].Value
+
+$CompilerCandidates = @(
+ (Join-Path $env:LOCALAPPDATA "Programs/Inno Setup 6/ISCC.exe"),
+ "C:/Program Files (x86)/Inno Setup 6/ISCC.exe",
+ "C:/Program Files/Inno Setup 6/ISCC.exe"
+)
+$Compiler = $CompilerCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
+if ($null -eq $Compiler) {
+ throw "Inno Setup 6 is required. Install it with: winget install JRSoftware.InnoSetup"
+}
+
+Remove-Item $OutputDir -Recurse -Force -ErrorAction SilentlyContinue
+New-Item -ItemType Directory -Path $OutputDir | Out-Null
+
+& $Compiler `
+ "/DAppVersion=$AppVersion" `
+ "/DSourceDir=$SourceDir" `
+ "/DOutputDir=$OutputDir" `
+ "/DRepoRoot=$RepoRoot" `
+ $InstallerScript
+
+$Installer = Join-Path $OutputDir "UsageSetup-$AppVersion.exe"
+if (-not (Test-Path -LiteralPath $Installer -PathType Leaf)) {
+ throw "Inno Setup did not produce $Installer"
+}
+
+Write-Output $Installer
diff --git a/tests/test_codex_loader.py b/tests/test_codex_loader.py
index 8f41f91..14fb38f 100644
--- a/tests/test_codex_loader.py
+++ b/tests/test_codex_loader.py
@@ -1967,6 +1967,68 @@ def test_load_rate_limits_picks_most_recent_valid(monkeypatch: pytest.MonkeyPatc
assert result.updated_at == new_ts
+def test_load_rate_limits_prefers_general_limit_over_newer_model_limit(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ sessions_dir = tmp_path / "sessions"
+ monkeypatch.setattr(codex_loader, "SESSIONS_DIR", sessions_dir)
+ monkeypatch.setattr(codex_loader, "_load_thread_models", lambda: {})
+ general = {
+ "limit_id": "codex",
+ "primary": {"used_percent": 72, "window_minutes": 10080, "resets_at": 9_999_999_999},
+ "secondary": None,
+ }
+ model_specific = {
+ "limit_id": "codex_bengalfox",
+ "primary": {"used_percent": 0, "window_minutes": 10080, "resets_at": 9_999_999_999},
+ "secondary": None,
+ }
+ _write_rate_limit_session(sessions_dir / "general.jsonl", "2026-08-12T02:24:00+00:00", general, 100)
+ _write_rate_limit_session(sessions_dir / "spark.jsonl", "2026-08-12T02:39:00+00:00", model_specific, 200)
+
+ result = codex_loader.load_rate_limits()
+
+ assert result is not None
+ assert result.limit_id == "codex"
+ assert result.seven_day_pct == 72.0
+
+
+def test_load_rate_limits_prefers_general_limit_within_same_session(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ sessions_dir = tmp_path / "sessions"
+ monkeypatch.setattr(codex_loader, "SESSIONS_DIR", sessions_dir)
+ monkeypatch.setattr(codex_loader, "_load_thread_models", lambda: {})
+ path = sessions_dir / "mixed.jsonl"
+ general = {
+ "limit_id": "codex",
+ "primary": {"used_percent": 72, "window_minutes": 10080, "resets_at": 9_999_999_999},
+ }
+ model_specific = {
+ "limit_id": "codex_bengalfox",
+ "primary": {"used_percent": 0, "window_minutes": 10080, "resets_at": 9_999_999_999},
+ }
+ _write_session(
+ path,
+ session_id="mixed",
+ timestamp="2026-08-12T02:24:00+00:00",
+ rate_limits=general,
+ mtime=200,
+ )
+ with path.open("a", encoding="utf-8") as file:
+ file.write("\n" + json.dumps({
+ "type": "event_msg",
+ "timestamp": "2026-08-12T02:39:00+00:00",
+ "payload": {"type": "token_count", "rate_limits": model_specific},
+ }))
+
+ result = codex_loader.load_rate_limits()
+
+ assert result is not None
+ assert result.limit_id == "codex"
+ assert result.seven_day_pct == 72.0
+
+
def test_recent_jsonl_files_sorts_visible_sessions_by_mtime(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
diff --git a/tests/test_dynamic_height.py b/tests/test_dynamic_height.py
index 3382a4d..5995a4b 100644
--- a/tests/test_dynamic_height.py
+++ b/tests/test_dynamic_height.py
@@ -27,7 +27,10 @@ def test_script_wraps_state_application_and_measures_without_height_constraints(
assert 'element.style.minHeight = "0"' in CONTENT_HEIGHT_SCRIPT
assert "wrap.getBoundingClientRect()" in CONTENT_HEIGHT_SCRIPT
assert 'action: "content_height"' in CONTENT_HEIGHT_SCRIPT
- assert "requestAnimationFrame" not in CONTENT_HEIGHT_SCRIPT
+ assert "requestAnimationFrame" in CONTENT_HEIGHT_SCRIPT
+ assert "MutationObserver" in CONTENT_HEIGHT_SCRIPT
+ assert "ResizeObserver" in CONTENT_HEIGHT_SCRIPT
+ assert "requestContentHeight();" in CONTENT_HEIGHT_SCRIPT
# Panels draw their edges with padding on whichever layer wraps .wrap, and
# the viewport-based panels nest an extra padded .viewport in between, so
# the whole ancestor chain has to be released and measured — assuming a
diff --git a/tests/test_menubar.py b/tests/test_menubar.py
index cea2016..8d8413b 100644
--- a/tests/test_menubar.py
+++ b/tests/test_menubar.py
@@ -165,6 +165,7 @@ def _build_popover_state(
),
agy_group_name="",
projects=[],
+ projects_yesterday=[],
projects_7d=[],
projects_30d=[],
projects_all=[],
@@ -172,6 +173,7 @@ def _build_popover_state(
group=delegate.tracker.group(),
burn_rate_trackers=delegate.burn_rate_trackers,
today_text=menubar._today_title(delegate.mock, delegate.language),
+ yesterday_text=menubar_state._yesterday_title(delegate.mock, delegate.language),
statusline=menubar._statusline_payload(delegate.language),
show_install_button=(
not hide_claude
diff --git a/tests/test_menubar_state.py b/tests/test_menubar_state.py
index be2e8a0..7c6b423 100644
--- a/tests/test_menubar_state.py
+++ b/tests/test_menubar_state.py
@@ -374,25 +374,33 @@ def entry(project: str, timestamp: datetime, tokens: int) -> UsageEntry:
project=project,
)
- rows_24h, rows_7d, rows_30d, rows_all = menubar_state.project_rows_for_windows(
+ rows_24h, rows_yesterday, rows_7d, rows_30d, rows_all = (
+ menubar_state.project_rows_for_windows(
[
entry("today", now, 4),
+ entry("yesterday", now - timedelta(days=1), 5),
entry("week", now - timedelta(days=2), 3),
entry("month", now - timedelta(days=10), 2),
entry("old", now - timedelta(days=40), 1),
],
now=now,
+ )
)
assert rows_24h == [("today", 4, 4.0)]
- assert rows_7d == [("today", 4, 4.0), ("week", 3, 3.0)]
+ assert rows_yesterday == [("yesterday", 5, 5.0)]
+ assert rows_7d == [
+ ("yesterday", 5, 5.0),
+ ("today", 4, 4.0),
+ ("week", 3, 3.0),
+ ]
assert rows_30d == [
+ ("yesterday", 5, 5.0),
("today", 4, 4.0),
("week", 3, 3.0),
- ("month", 2, 2.0),
]
assert rows_all == [
+ ("yesterday", 5, 5.0),
("today", 4, 4.0),
("week", 3, 3.0),
- ("month", 2, 2.0),
]
diff --git a/tests/test_panels.py b/tests/test_panels.py
index d86e0e8..b206208 100644
--- a/tests/test_panels.py
+++ b/tests/test_panels.py
@@ -226,6 +226,17 @@ def test_classic_project_header_expands_for_action_row() -> None:
assert "margin-bottom: 10px;" in project_brand_css
+def test_shared_core_prioritizes_codex_title_when_switch_moves_into_header() -> None:
+ core = (
+ Path(__file__).resolve().parent.parent / "assets" / "panels" / "panel_core.js"
+ ).read_text(encoding="utf-8")
+
+ assert 'host.classList.add("usage-switch-host")' in core
+ assert '.usage-switch-host > h1' in core
+ assert '.usage-switch-host > .header-copy' in core
+ assert '.usage-switch-host > [data-codex-stale]' in core
+
+
def test_missing_panel_id_falls_back_to_classic() -> None:
panel = panels.get_panel("missing")
diff --git a/tests/test_usage_lang.py b/tests/test_usage_lang.py
index f7de52f..1cebf57 100644
--- a/tests/test_usage_lang.py
+++ b/tests/test_usage_lang.py
@@ -112,3 +112,15 @@ def test_detect_lang_env_var_beats_windows_ui_language(
_fake_windll(monkeypatch, 1028)
assert detect_lang() == "ja"
+
+
+def test_detect_lang_ignores_posix_lang_on_windows(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.delenv("USAGE_LANG", raising=False)
+ monkeypatch.delenv("TT_LANG", raising=False)
+ monkeypatch.setenv("LANG", "C.UTF-8")
+ monkeypatch.setattr(sys, "platform", "win32")
+ _fake_windll(monkeypatch, 2052)
+
+ assert detect_lang() == "zh-CN"
diff --git a/tests/test_wintray.py b/tests/test_wintray.py
index 648ffbd..ca5900a 100644
--- a/tests/test_wintray.py
+++ b/tests/test_wintray.py
@@ -70,12 +70,14 @@ def _state() -> menubar_state.PopoverState:
agy_weekly=weekly,
agy_group_name="",
projects=[],
+ projects_yesterday=[],
projects_7d=[],
projects_30d=[],
projects_all=[],
rate_text="",
status_text="",
today_text="",
+ yesterday_text="",
statusline={},
)
@@ -156,6 +158,13 @@ def test_panel_html_installs_webkit_shim_without_changing_asset() -> None:
assert "window.pywebview.api.postMessage(message)" in html
assert "pywebview-drag-region" in html
assert "usage-window-drag-handle" in html
+ assert "usage-window-controls" in html
+ assert "dataset.usageWindowAction = 'hide'" in html
+ assert "dataset.i18nTitle = 'close_to_tray'" in html
+ assert "minimize_to_tray" not in html
+ assert "window.usagePostPanelAction = post" in html
+ assert "window.usagePostPanelAction('hide_panel')" in html
+ assert "window.addEventListener('blur'" not in html
assert "post('open_menu')" in html
assert "usage-panel-menu-backdrop" in html
assert "usage-panel-menu-accordion" in html
@@ -178,6 +187,7 @@ def test_content_height_message_resizes_visible_panel_with_work_area_clamp(
controller.visible = True
calls: list[str] = []
monkeypatch.setattr(controller, "_working_area", lambda: (0, 0, 1000, 800))
+ monkeypatch.setattr(controller, "_work_area_for_point", lambda _point: None)
monkeypatch.setattr(controller, "_place_window", lambda: calls.append("place"))
controller.handle_panel_message(
@@ -205,7 +215,7 @@ def test_invalid_content_height_keeps_registered_fallback(
assert controller.panel_height() == fallback
-def test_panel_position_is_clamped_and_persisted_on_hide(
+def test_panel_position_defaults_to_bottom_right_and_is_not_persisted_on_hide(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
preferences_path = tmp_path / "usage-preferences.json"
@@ -220,6 +230,7 @@ def test_panel_position_is_clamped_and_persisted_on_hide(
resize=lambda *args: None,
move=lambda x, y: moves.append((x, y)),
hide=lambda: None,
+ minimize=lambda: None,
)
controller = wintray._WindowsTrayController(mock=True, interval=60)
controller.window = window
@@ -229,10 +240,10 @@ def test_panel_position_is_clamped_and_persisted_on_hide(
controller._place_window()
- assert moves == [(608, 12)]
+ assert moves == [(608, 64)]
window.x, window.y = 123, 234
controller.show_panel()
- assert prefs._load_preferences()["usage.windowPosition"] == {"x": 123, "y": 234}
+ assert prefs._load_preferences()["usage.windowPosition"] == {"x": 5000, "y": -100}
def test_load_preferences_non_utf8(
@@ -266,7 +277,7 @@ def test_reset_panel_position_clears_preference_and_repositions_visible_window(
assert calls == [True]
-def test_switch_panel_keeps_dragged_position_before_new_height_is_measured(
+def test_switch_panel_reanchors_to_primary_bottom_right_after_height_measurement(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -301,14 +312,14 @@ def test_switch_panel_keeps_dragged_position_before_new_height_is_measured(
controller.switch_panel("cloud_observation") # PANEL_HEIGHTS[...] == 1006
controller.on_loaded()
- assert moves[-1] == (300, 200)
+ assert moves[-1] == (1528, 368)
controller.handle_panel_message(json.dumps({"action": "content_height", "height": 650}))
- assert moves[-1] == (300, 200)
+ assert moves[-1] == (1528, 418)
-def test_switch_panel_keeps_dragged_position_on_secondary_monitor(
+def test_switch_panel_ignores_stale_secondary_position(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -351,7 +362,7 @@ def work_area_for_point(point: tuple[int, int] | None) -> tuple[int, int, int, i
controller.switch_panel("cloud_observation")
controller.on_loaded()
- assert moves[-1] == (2200, 300)
+ assert moves[-1] == (1528, 368)
def test_js_api_forwards_panel_message() -> None:
@@ -383,7 +394,7 @@ def test_switch_panel_message_returns_menu_instead_of_cycling(
menu = controller.handle_panel_message("switch")
assert isinstance(menu, list)
- assert menu[2]["i18nKey"] == "switch_panel"
+ assert menu[3]["i18nKey"] == "switch_panel"
assert switched_to == []
@@ -425,6 +436,7 @@ def test_panel_menu_data_is_localized_and_reads_current_checks(
) -> None:
controller = wintray._WindowsTrayController(mock=True, interval=60)
controller.language = "en"
+ controller.language_preference = "en"
controller.active_panel_id = "matrix"
monkeypatch.setattr(wintray, "_hide_claude_enabled", lambda: True)
monkeypatch.setattr(wintray, "_hide_codex_enabled", lambda: False)
@@ -444,9 +456,11 @@ def test_panel_menu_data_is_localized_and_reads_current_checks(
}
assert [entry.get("i18nKey", entry.get("type")) for entry in menu] == [
"panel_ai_daily",
+ "reset_panel_position",
"separator",
"switch_panel",
"hide_sections_menu",
+ "language_menu",
"separator",
"launch_at_login",
"quota_notifications_menu",
@@ -456,17 +470,22 @@ def test_panel_menu_data_is_localized_and_reads_current_checks(
"terse_mode_menu",
"separator",
"refresh_now",
+ "check_update",
+ "quit",
]
- panels = cast(list[dict[str, object]], menu[2]["children"])
- hidden_sections = cast(list[dict[str, object]], menu[3]["children"])
+ panels = cast(list[dict[str, object]], menu[3]["children"])
+ hidden_sections = cast(list[dict[str, object]], menu[4]["children"])
+ languages = cast(list[dict[str, object]], menu[5]["children"])
assert panels[1]["panelId"] == "matrix"
assert panels[1]["checked"] is True
assert [item["checked"] for item in hidden_sections] == [True, False, True]
- assert menu[5]["checked"] is True
- assert menu[6]["checked"] is False
+ assert [item["languageCode"] for item in languages] == list(wintray.LANGUAGE_OPTIONS)
+ assert [item["checked"] for item in languages] == [False, False, False, True, False, False]
assert menu[7]["checked"] is True
+ assert menu[8]["checked"] is False
assert menu[9]["checked"] is True
- assert menu[10]["checked"] is False
+ assert menu[11]["checked"] is True
+ assert menu[12]["checked"] is False
@pytest.mark.parametrize(
@@ -486,6 +505,8 @@ def test_panel_menu_data_is_localized_and_reads_current_checks(
({"action": "toggle_window_keeper"}, "toggle_window_keeper", ()),
({"action": "toggle_session_resume"}, "toggle_session_resume", ()),
({"action": "toggle_terse_mode"}, "toggle_terse_mode", ()),
+ ({"action": "set_language", "language_code": "zh-CN"}, "set_language", ("zh-CN",)),
+ ({"action": "hide_panel"}, "hide_panel", ()),
({"action": "check_update"}, "check_update", ()),
({"action": "quit"}, "quit", ()),
],
@@ -505,6 +526,38 @@ def test_panel_menu_actions_dispatch_to_controller_methods(
assert calls == [expected]
+def test_set_language_persists_and_updates_visible_panel(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ preferences: dict[str, object] = {"unrelated": True}
+ evaluated: list[str] = []
+ refreshed: list[str] = []
+ controller = wintray._WindowsTrayController(mock=True, interval=60)
+ controller.visible = True
+ controller.window = SimpleNamespace(evaluate_js=evaluated.append)
+ monkeypatch.setattr(wintray, "_load_preferences", lambda: preferences.copy())
+ monkeypatch.setattr(wintray, "_save_preferences", lambda value: preferences.update(value))
+ monkeypatch.setattr(controller, "refresh", lambda: refreshed.append("refresh"))
+
+ controller.set_language("zh-CN")
+
+ assert preferences == {"unrelated": True, "usage.language": "zh-CN"}
+ assert controller.language_preference == "zh-CN"
+ assert controller.language == "zh-CN"
+ assert evaluated == ['window.usageSetLanguage("zh-CN")']
+ assert refreshed == ["refresh"]
+
+
+def test_set_language_rejects_unknown_value(monkeypatch: pytest.MonkeyPatch) -> None:
+ saved: list[dict[str, object]] = []
+ controller = wintray._WindowsTrayController(mock=True, interval=60)
+ monkeypatch.setattr(wintray, "_save_preferences", saved.append)
+
+ controller.set_language("de")
+
+ assert saved == []
+
+
@pytest.mark.parametrize("panel_id", ["matrix", "aquarium", "win95"])
def test_card_order_persists_into_the_next_loaded_panel(
monkeypatch: pytest.MonkeyPatch,
@@ -544,6 +597,10 @@ def test_card_order_persists_into_the_next_loaded_panel(
assert json.loads(payload)["cardOrder"] == order
+def test_physical_work_area_is_converted_to_pywebview_logical_coordinates() -> None:
+ assert wintray._logical_work_area((0, 0, 2560, 1528), 144) == (0, 0, 1707, 1019)
+
+
def test_run_app_wires_pystray_and_pywebview(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -569,8 +626,11 @@ def __iadd__(self, callback: object) -> Event:
events.append("loaded_handler")
return self
- window = SimpleNamespace(events=SimpleNamespace(loaded=Event()))
-
+ window = SimpleNamespace(
+ events=SimpleNamespace(
+ loaded=Event(), minimized=Event(), restored=Event(), closing=Event()
+ )
+ )
def create_window(*args: object, **kwargs: object) -> object:
events.append(
("window", args[0], kwargs["hidden"], kwargs["background_color"])
@@ -596,6 +656,9 @@ def create_window(*args: object, **kwargs: object) -> object:
assert events == [
("window", "usage", True, "#eef2f7"),
"loaded_handler",
+ "loaded_handler",
+ "loaded_handler",
+ "loaded_handler",
("icon", "usage"),
"run_detached",
("start", "edgechromium"),
@@ -631,13 +694,67 @@ def test_show_panel_places_window_before_showing(
)
monkeypatch.setattr(controller, "refresh", lambda: calls.append("refresh"))
controller.window = SimpleNamespace(
- show=lambda: calls.append("show"), hide=lambda: calls.append("hide")
+ restore=lambda: calls.append("restore"),
+ show=lambda: calls.append("show"),
+ hide=lambda: calls.append("hide"),
)
controller.show_panel()
assert controller.visible is True
- assert calls == ["place", "show", "inject:True", "refresh"]
+ assert calls == ["place", "restore", "show", "inject:True", "refresh"]
+
+
+def test_hide_panel_hides_window_to_tray(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ controller = wintray._WindowsTrayController(mock=True, interval=60)
+ controller.visible = True
+ calls: list[str] = []
+ controller.window = SimpleNamespace(hide=lambda: calls.append("hide"))
+
+ controller.hide_panel()
+ controller.hide_panel()
+
+ assert controller.visible is False
+ assert controller._positioned_this_show is False
+ assert calls == ["hide"]
+
+
+def test_native_close_hides_to_tray_and_only_quit_allows_destroy() -> None:
+ controller = wintray._WindowsTrayController(mock=True, interval=60)
+ calls: list[str] = []
+ controller.visible = True
+ controller.window = SimpleNamespace(hide=lambda: calls.append("hide"))
+
+ assert controller.on_closing() is False
+ assert controller.visible is False
+ assert calls == ["hide"]
+
+ controller.stopping.set()
+ assert controller.on_closing() is True
+
+
+def test_native_minimize_and_restore_keep_controller_state_in_sync(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ controller = wintray._WindowsTrayController(mock=True, interval=60)
+ calls: list[str] = []
+ monkeypatch.setattr(
+ controller,
+ "_place_window",
+ lambda *, force_default=True: calls.append(f"place:{force_default}"),
+ )
+ monkeypatch.setattr(
+ controller, "inject_state", lambda *, force=False: calls.append(f"inject:{force}")
+ )
+
+ controller.on_minimized()
+ assert controller.visible is False
+ controller.on_restored()
+
+ assert controller.visible is True
+ assert calls == ["place:True", "inject:True"]
def test_tray_update_skips_unchanged_values(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -670,8 +787,10 @@ def test_inject_state_skips_duplicate_but_forces_after_panel_reopens(
injected: list[str] = []
controller.window = SimpleNamespace(
evaluate_js=injected.append,
+ restore=lambda: None,
show=lambda: None,
hide=lambda: None,
+ minimize=lambda: None,
)
monkeypatch.setattr(controller, "_place_window", lambda: None)
monkeypatch.setattr(controller, "refresh", lambda: None)
@@ -810,14 +929,12 @@ def record(name: str) -> int:
assert calls == ["enable_resume", "disable_terse"]
-def test_run_app_bails_out_when_another_instance_holds_the_lock(
+def test_run_app_bails_out_silently_when_another_instance_is_activated(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Regression: a second tray instance used to fight the first over the
# WebView2 user-data directory and linger as a bare white window.
- notices: list[str] = []
monkeypatch.setattr(wintray, "_acquire_single_instance_lock", lambda: False)
- monkeypatch.setattr(wintray, "_show_already_running_notice", lambda: notices.append("shown"))
fake_webview = SimpleNamespace(
create_window=lambda *args, **kwargs: pytest.fail("window must not be created"),
start=lambda **kwargs: pytest.fail("webview must not start"),
@@ -826,7 +943,28 @@ def test_run_app_bails_out_when_another_instance_holds_the_lock(
wintray.run_app(mock=True, interval=60)
- assert notices == ["shown"]
+
+
+def test_activate_panel_opens_hidden_or_restores_visible(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ controller = wintray._WindowsTrayController(mock=True, interval=60)
+ calls: list[str] = []
+ controller.window = SimpleNamespace(
+ restore=lambda: calls.append("restore"), show=lambda: calls.append("show")
+ )
+ monkeypatch.setattr(controller, "show_panel", lambda: calls.append("open"))
+ monkeypatch.setattr(
+ controller,
+ "_place_window",
+ lambda *, force_default=True: calls.append(f"place:{force_default}"),
+ )
+
+ controller.activate_panel()
+ controller.visible = True
+ controller.activate_panel()
+
+ assert calls == ["open", "restore", "show", "place:True"]
@pytest.mark.skipif(sys.platform != "win32", reason="Windows named mutex")
@@ -855,8 +993,10 @@ def test_menu_actions_pass_real_pystray_signature_validation() -> None:
pytest.importorskip("pystray", reason="pystray is a Windows-only extra")
controller = SimpleNamespace(
language="en",
+ language_preference="auto",
active_panel_id="classic",
switch_panel=lambda panel_id: None,
+ set_language=lambda code: None,
show_panel=lambda: None,
reset_panel_position=lambda: None,
refresh=lambda: None,
diff --git a/usage_lang.py b/usage_lang.py
index 52933a3..5df593f 100644
--- a/usage_lang.py
+++ b/usage_lang.py
@@ -64,7 +64,11 @@ def _detect_windows_lang() -> str:
def detect_lang(env: Mapping[str, str] | None = None) -> str:
source = os.environ if env is None else env
- for key in ("USAGE_LANG", "TT_LANG", "LANG"):
+ override_keys = ("USAGE_LANG", "TT_LANG", "LANG") if env is not None else (
+ "USAGE_LANG",
+ "TT_LANG",
+ )
+ for key in override_keys:
value = source.get(key, "").strip()
if value:
return _normalize_lang(value)
diff --git a/wintray.py b/wintray.py
index 5e92f01..4d5244c 100644
--- a/wintray.py
+++ b/wintray.py
@@ -12,7 +12,7 @@
import tomllib
import webbrowser
from dataclasses import dataclass
-from datetime import datetime
+from datetime import datetime, timedelta
from importlib import metadata
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -56,6 +56,19 @@
SLOW_POLL_INTERVAL_S = 300
HISTORY_SCAN_CACHE_SECONDS = 30.0
PANEL_WIDTH = 380
+LANGUAGE_PREFERENCE_KEY = "usage.language"
+LANGUAGE_OPTIONS = ("auto", "zh-CN", "zh-TW", "en", "ja", "ko")
+
+
+def _logical_work_area(
+ work_area: tuple[int, int, int, int], dpi: int,
+) -> tuple[int, int, int, int]:
+ scale = max(96, dpi) / 96.0
+ values = (round(value / scale) for value in work_area)
+ left, top, right, bottom = values
+ return (left, top, right, bottom)
+
+
WINDOWS_PANELS = (
("classic", "panel_default_name", "classic.html"),
("matrix", "panel_matrix", "matrix.html"),
@@ -157,7 +170,8 @@
row.textContent = (item.checked ? '✓ ' : ' ') + item.label;
row.addEventListener('click', function() {
var extra = item.panelId ? { panel_id: item.panelId } :
- item.preferenceKey ? { preference_key: item.preferenceKey } : undefined;
+ item.preferenceKey ? { preference_key: item.preferenceKey } :
+ item.languageCode ? { language_code: item.languageCode } : undefined;
post(item.action, extra);
closeMenu();
});
@@ -192,6 +206,7 @@
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') closeMenu();
});
+ window.usagePostPanelAction = post;
})();
// Panel assets register their card reorder handler in the bubbling phase. This
@@ -226,7 +241,27 @@
handle.className = 'usage-window-drag-handle pywebview-drag-region';
handle.setAttribute('aria-hidden', 'true');
document.body.appendChild(handle);
+
+ var controls = document.createElement('div');
+ controls.className = 'usage-window-controls';
+ var button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'usage-window-control';
+ button.textContent = '×';
+ button.dataset.usageWindowAction = 'hide';
+ button.dataset.i18nTitle = 'close_to_tray';
+ controls.appendChild(button);
+ document.body.appendChild(controls);
});
+
+document.addEventListener('click', function(event) {
+ var button = event.target.closest && event.target.closest('[data-usage-window-action="hide"]');
+ if (!button) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ window.usagePostPanelAction('hide_panel');
+}, true);
+