diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ced47e5..366b89d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to usage are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/). +## Unreleased + +### Fixed +- **Windows hook and setup output now uses UTF-8**: statusLine bars keep their intended Unicode glyphs when Claude Code reads a pipe, and `--setup` / `--unsetup` no longer fail in legacy cp950 consoles when localized messages contain characters such as ✓. +- **Windows panel controls now fit and follow the macOS grouping**: Change Panel and Hide Sections expand in place inside a scrollable menu instead of opening clipped side submenus; the panel menu now ends with only Refresh Now, avoiding duplicate update, position-reset, and quit actions. +- **Windows quota-card empty areas now drag the panel rather than reorder cards**: the Windows shim temporarily marks only non-interactive card presses as native drag regions, retaining saved card order while buttons and links remain clickable. + +### Added +- **Windows panel switch buttons now open a focused controls menu**: clicking a panel's built-in Switch Panel button opens a localized HTML overlay for panel, visibility, refresh, notification, and workflow controls; tray-only update, position-reset, and quit actions stay out of the panel menu. +- **Windows tray panels can now be repositioned**: a subtle top drag handle and quota-card empty areas move frameless panels with grab/grabbing cursors; the position is restored on the next open, clamped to the current work area, and can be reset from the tray menu. +- **Windows tray menu parity for daily updates and workflow controls**: the Windows system tray now links to AI Update Daily, offers a Hide Sections submenu for Claude, Codex, and Antigravity, supports quota-alert notifications at the same thresholds as macOS, and exposes Resume Last Session and Token Saver toggles. Changes to visible sections are injected into an open panel immediately; quota alerts use native Windows tray notifications. The macOS-only AI Talent Market remains unavailable on Windows. + ## [0.28.5] - 2026-07-16 ### Fixed diff --git a/CHANGELOG.zh-TW.md b/CHANGELOG.zh-TW.md index a593e372..583e4f5e 100644 --- a/CHANGELOG.zh-TW.md +++ b/CHANGELOG.zh-TW.md @@ -4,6 +4,18 @@ 本檔記錄 usage 所有重要變更。格式參考 [Keep a Changelog](https://keepachangelog.com/)。 +## 未發行 + +### 修正 +- **Windows hook 與設定訊息現在使用 UTF-8**:Claude Code 從管道讀取 statusLine 時會保留進度條的 Unicode 字元;在傳統 cp950 主控台中,`--setup`/`--unsetup` 遇到 ✓ 等字元的在地化訊息也不再失敗。 +- **Windows 面板控制選單現在能完整顯示,且分組與 macOS 一致**:「更換面板」與「隱藏區塊」會在可捲動選單內就地展開,不再開啟可能被裁切的側邊子選單;面板選單最後只保留「立即更新」,避免重複的更新、位置重設與結束操作。 +- **Windows 額度卡片的空白區域現在會拖曳面板而非重排卡片**:Windows shim 只會暫時把非互動式卡片按壓標記為原生拖曳區,保留已儲存的卡片順序,按鈕與連結仍可正常點擊。 + +### 新增 +- **Windows 面板的更換面板按鈕現在會開啟精簡控制選單**:點擊各主題內建的「更換面板」按鈕,會顯示本地化 HTML 覆蓋層,提供面板、可見區塊、重新整理、通知與工作流程控制;僅系統匣使用的更新、位置重設與結束操作不會出現在面板選單。 +- **Windows 系統匣面板現在可移動**:無邊框面板頂端拖曳把手與額度卡片空白區都可移動視窗,並使用抓取/抓取中游標;位置會在下次開啟時還原並限制於目前工作區,也可從系統匣選單重設。 +- **Windows 系統匣選單補齊每日更新與工作流程控制**:Windows 系統匣現在可開啟 AI 更新日報,並提供 Claude、Codex、Antigravity 的「隱藏區塊」子選單;用量提醒通知沿用與 macOS 相同的跨門檻判斷,也加入「接著上次做」與「省 token 模式」開關。可見區塊的變更會立即注入目前開啟的面板,額度提醒則使用原生 Windows 系統匣通知。僅限 macOS 的 AI 人才市場仍不會在 Windows 出現。 + ## [0.28.5] - 2026-07-16 ### 修正 diff --git a/i18n.json b/i18n.json index 5ba3e41e..97e51f46 100644 --- a/i18n.json +++ b/i18n.json @@ -27,6 +27,7 @@ "talent_empty": "人才市場元件尚未安裝", "talent_empty_hint": "重新打包 .app 後即會帶入角色市集", "launch_at_login": "開機自動啟動", + "reset_panel_position": "重設面板位置", "hide_sections_menu": "隱藏區塊", "quota_notifications_menu": "用量提醒通知", "notif_warn_title": "🐾 快用完囉", @@ -390,6 +391,7 @@ "talent_empty": "Talent market component is not installed", "talent_empty_hint": "Rebuild the .app to bundle the role marketplace", "launch_at_login": "Launch at Login", + "reset_panel_position": "Reset panel position", "hide_sections_menu": "Hide Sections", "quota_notifications_menu": "Usage Alert Notifications", "notif_warn_title": "🐾 Almost out", @@ -753,6 +755,7 @@ "talent_empty": "人才市场元件尚未安装", "talent_empty_hint": "重新打包 .app 后即会带入角色市场", "launch_at_login": "开机自动启动", + "reset_panel_position": "重设面板位置", "hide_sections_menu": "隐藏区块", "quota_notifications_menu": "用量提醒通知", "notif_warn_title": "🐾 快用完啦", @@ -1116,6 +1119,7 @@ "talent_empty": "人材マーケットコンポーネントがインストールされていません", "talent_empty_hint": ".app を再ビルドするとロールマーケットが同梱されます", "launch_at_login": "ログイン時に起動", + "reset_panel_position": "パネルの位置をリセット", "hide_sections_menu": "セクションを非表示", "quota_notifications_menu": "使用量アラート通知", "notif_warn_title": "🐾 もうすぐ使い切り", @@ -1479,6 +1483,7 @@ "talent_empty": "인재 마켓 구성 요소가 설치되지 않았습니다", "talent_empty_hint": ".app을 다시 빌드하면 롤 마켓이 포함됩니다", "launch_at_login": "로그인 시 시작", + "reset_panel_position": "패널 위치 재설정", "hide_sections_menu": "섹션 숨기기", "quota_notifications_menu": "사용량 알림", "notif_warn_title": "🐾 거의 다 썼어요", diff --git a/session_hooks.py b/session_hooks.py index b6600e8e..013b484b 100644 --- a/session_hooks.py +++ b/session_hooks.py @@ -657,6 +657,7 @@ def is_terse_reminder_enabled() -> bool: def enable_session_resume() -> int: + setup_hook.configure_windows_utf8_output() if not CLAUDE_SETTINGS.parent.exists(): print(_t("setup_no_agents"), file=sys.stderr) return 1 @@ -682,6 +683,7 @@ def enable_session_resume() -> int: def enable_terse_mode() -> int: + setup_hook.configure_windows_utf8_output() if not CLAUDE_SETTINGS.parent.exists(): print(_t("setup_no_agents"), file=sys.stderr) return 1 @@ -711,6 +713,7 @@ def enable_terse_mode() -> int: def disable_session_resume() -> int: + setup_hook.configure_windows_utf8_output() if CLAUDE_SETTINGS.parent.exists(): settings = _load_settings() session_start = _session_start_list(settings) @@ -733,6 +736,7 @@ def disable_session_resume() -> int: def disable_terse_mode() -> int: + setup_hook.configure_windows_utf8_output() if CLAUDE_SETTINGS.parent.exists(): settings = _load_settings() changed = False diff --git a/setup_hook.py b/setup_hook.py index bfa8d731..47953749 100644 --- a/setup_hook.py +++ b/setup_hook.py @@ -30,7 +30,7 @@ import tomllib from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, cast from i18n import t as _t @@ -65,6 +65,16 @@ _TABLE_REGEX = re.compile(r"(?m)^[ \t]*\[[^\]\n]+\][ \t]*(?:#.*)?$") +def configure_windows_utf8_output() -> None: + """Prevent Windows legacy console encodings from rejecting CLI messages.""" + if os.name != "nt": + return + for stream in (sys.stdout, sys.stderr): + with contextlib.suppress(AttributeError, OSError, ValueError): + # Test runners and embedders may replace the TextIOWrapper streams. + cast(Any, stream).reconfigure(encoding="utf-8") + + def _resolve_hook_source() -> Path: paths = [ Path(__file__).resolve().parent / "usage_statusline.py", @@ -620,6 +630,7 @@ def _install_forwarder(settings: dict[str, Any]) -> None: def setup(force_forwarder: bool = False) -> int: + configure_windows_utf8_output() _migrate_from_legacy_usage() has_claude = CLAUDE_SETTINGS.parent.exists() has_codex = CODEX_CONFIG.exists() @@ -658,6 +669,7 @@ def setup(force_forwarder: bool = False) -> int: def unsetup() -> int: + configure_windows_utf8_output() if CLAUDE_SETTINGS.parent.exists(): settings = _load_settings() sl = settings.get("statusLine") diff --git a/tests/test_session_resume.py b/tests/test_session_resume.py index 9b792349..6d33fcaa 100644 --- a/tests/test_session_resume.py +++ b/tests/test_session_resume.py @@ -8,14 +8,36 @@ import json import os +import sys from datetime import datetime, timedelta from pathlib import Path +from types import SimpleNamespace import pytest import usage_session_resume as mod +def test_windows_output_reconfigures_both_streams(monkeypatch: pytest.MonkeyPatch) -> None: + class Stream: + def __init__(self) -> None: + self.encodings: list[str] = [] + + def reconfigure(self, *, encoding: str) -> None: + self.encodings.append(encoding) + + stdout = Stream() + stderr = Stream() + monkeypatch.setattr(mod, "os", SimpleNamespace(name="nt")) + monkeypatch.setattr(sys, "stdout", stdout) + monkeypatch.setattr(sys, "stderr", stderr) + + mod._configure_windows_utf8_output() + + assert stdout.encodings == ["utf-8"] + assert stderr.encodings == ["utf-8"] + + def _write_session( path: Path, *, diff --git a/tests/test_setup_hook.py b/tests/test_setup_hook.py index 0846d481..b6f32ba7 100644 --- a/tests/test_setup_hook.py +++ b/tests/test_setup_hook.py @@ -11,6 +11,7 @@ import tomllib from collections.abc import Callable from pathlib import Path +from types import SimpleNamespace import pytest @@ -21,6 +22,26 @@ LEGACY_NAME = "usag" +def test_windows_cli_output_reconfigures_both_streams(monkeypatch: pytest.MonkeyPatch) -> None: + class Stream: + def __init__(self) -> None: + self.encodings: list[str] = [] + + def reconfigure(self, *, encoding: str) -> None: + self.encodings.append(encoding) + + stdout = Stream() + stderr = Stream() + monkeypatch.setattr(setup_hook, "os", SimpleNamespace(name="nt")) + monkeypatch.setattr(sys, "stdout", stdout) + monkeypatch.setattr(sys, "stderr", stderr) + + setup_hook.configure_windows_utf8_output() + + assert stdout.encodings == ["utf-8"] + assert stderr.encodings == ["utf-8"] + + @pytest.fixture def setup_paths(patch_setup_hook_paths: Callable[..., SetupHookPaths]) -> SetupHookPaths: return patch_setup_hook_paths(legacy_name=LEGACY_NAME) diff --git a/tests/test_usage_statusline.py b/tests/test_usage_statusline.py index 901496f6..4e4f54f2 100644 --- a/tests/test_usage_statusline.py +++ b/tests/test_usage_statusline.py @@ -14,6 +14,7 @@ import tempfile from datetime import UTC, datetime from pathlib import Path +from types import SimpleNamespace from typing import Any import pytest @@ -21,6 +22,34 @@ import usage_statusline +def test_windows_output_reconfigures_both_streams(monkeypatch: pytest.MonkeyPatch) -> None: + class Stream: + def __init__(self) -> None: + self.encodings: list[str] = [] + + def reconfigure(self, *, encoding: str) -> None: + self.encodings.append(encoding) + + stdout = Stream() + stderr = Stream() + monkeypatch.setattr(usage_statusline, "os", SimpleNamespace(name="nt")) + monkeypatch.setattr(sys, "stdout", stdout) + monkeypatch.setattr(sys, "stderr", stderr) + + usage_statusline._configure_windows_utf8_output() + + assert stdout.encodings == ["utf-8"] + assert stderr.encodings == ["utf-8"] + + +def test_windows_output_tolerates_replaced_streams(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(usage_statusline, "os", SimpleNamespace(name="nt")) + monkeypatch.setattr(sys, "stdout", object()) + monkeypatch.setattr(sys, "stderr", object()) + + usage_statusline._configure_windows_utf8_output() + + @pytest.fixture(autouse=True) def _isolate_context_burn_file( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_usage_statusline_forwarder.py b/tests/test_usage_statusline_forwarder.py index 6affc38d..8cc9ad7a 100644 --- a/tests/test_usage_statusline_forwarder.py +++ b/tests/test_usage_statusline_forwarder.py @@ -10,6 +10,7 @@ import subprocess import sys from importlib import import_module +from types import SimpleNamespace from typing import Any import pytest @@ -17,6 +18,26 @@ usage_statusline_forwarder: Any = import_module("usage_statusline_forwarder") +def test_windows_output_reconfigures_both_streams(monkeypatch: pytest.MonkeyPatch) -> None: + class Stream: + def __init__(self) -> None: + self.encodings: list[str] = [] + + def reconfigure(self, *, encoding: str) -> None: + self.encodings.append(encoding) + + stdout = Stream() + stderr = Stream() + monkeypatch.setattr(usage_statusline_forwarder, "os", SimpleNamespace(name="nt")) + monkeypatch.setattr(sys, "stdout", stdout) + monkeypatch.setattr(sys, "stderr", stderr) + + usage_statusline_forwarder._configure_windows_utf8_output() + + assert stdout.encodings == ["utf-8"] + assert stderr.encodings == ["utf-8"] + + def test_main_fans_stdin_out_to_all_hooks( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], diff --git a/tests/test_wintray.py b/tests/test_wintray.py index 60ce3ed0..a9016e40 100644 --- a/tests/test_wintray.py +++ b/tests/test_wintray.py @@ -6,13 +6,18 @@ import json import sys import threading +from pathlib import Path from types import SimpleNamespace +from typing import cast import pytest import menubar_prefs import menubar_state +import prefs +import win_login_item import wintray +from usage_notifications import NotificationEvent class _Key: @@ -148,6 +153,70 @@ def test_panel_html_installs_webkit_shim_without_changing_asset() -> None: assert "window.webkit.messageHandlers.usage" in html assert "window.pywebview.api.postMessage(message)" in html + assert "pywebview-drag-region" in html + assert "usage-window-drag-handle" in html + assert "post('open_menu')" in html + assert "usage-panel-menu-backdrop" in html + assert "usage-panel-menu-accordion" in html + assert "max-height: 80vh" in html + assert "overflow-y: auto" in html + assert "event.stopImmediatePropagation()" in html + assert "[data-card=\"claude\"]" in html + assert "usage-card-window-dragging" in html + assert "card.classList.add('pywebview-drag-region'" in html + assert "button, a, input, select, textarea, label, summary" in html + assert "cursor: grab" in html + assert "cursor: grabbing" in html + + +def test_panel_position_is_clamped_and_persisted_on_hide( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + preferences_path = tmp_path / "usage-preferences.json" + preferences_path.write_text( + json.dumps({"usage.windowPosition": {"x": 5000, "y": -100}}), encoding="utf-8" + ) + monkeypatch.setattr(prefs, "PREFERENCES_FILE", preferences_path) + moves: list[tuple[int, int]] = [] + window = SimpleNamespace( + x=0, + y=0, + resize=lambda *args: None, + move=lambda x, y: moves.append((x, y)), + hide=lambda: None, + ) + controller = wintray._WindowsTrayController(mock=True, interval=60) + controller.window = window + controller.visible = True + monkeypatch.setattr(controller, "_working_area", lambda: (0, 0, 1000, 1080)) + + controller._place_window() + + assert moves == [(608, 12)] + window.x, window.y = 123, 234 + controller.show_panel() + assert prefs._load_preferences()["usage.windowPosition"] == {"x": 123, "y": 234} + + +def test_reset_panel_position_clears_preference_and_repositions_visible_window( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + preferences_path = tmp_path / "usage-preferences.json" + preferences_path.write_text( + json.dumps({"usage.windowPosition": {"x": 123, "y": 234}}), encoding="utf-8" + ) + monkeypatch.setattr(prefs, "PREFERENCES_FILE", preferences_path) + controller = wintray._WindowsTrayController(mock=True, interval=60) + controller.visible = True + calls: list[bool] = [] + monkeypatch.setattr( + controller, "_place_window", lambda *, force_default=False: calls.append(force_default) + ) + + controller.reset_panel_position() + + assert prefs._load_preferences() == {} + assert calls == [True] def test_js_api_forwards_panel_message() -> None: @@ -159,7 +228,31 @@ def test_js_api_forwards_panel_message() -> None: assert received == ["refresh"] -def test_switch_panel_waits_for_bridge_promise_to_resolve( +def test_js_api_returns_panel_menu_data() -> None: + menu = [{"label": "Menu"}] + controller = SimpleNamespace(handle_panel_message=lambda _message: menu) + + result = wintray._JSApi(controller).postMessage("open_menu") # type: ignore[arg-type] + + assert result == menu + + +def test_switch_panel_message_returns_menu_instead_of_cycling( + monkeypatch: pytest.MonkeyPatch, +) -> None: + controller = wintray._WindowsTrayController(mock=True, interval=60) + switched_to: list[str] = [] + monkeypatch.setattr(controller, "switch_panel", switched_to.append) + monkeypatch.setattr(win_login_item, "is_enabled", lambda: True) + + menu = controller.handle_panel_message("switch") + + assert isinstance(menu, list) + assert menu[2]["i18nKey"] == "switch_panel" + assert switched_to == [] + + +def test_selected_panel_switch_waits_for_bridge_promise_and_debounces( monkeypatch: pytest.MonkeyPatch, ) -> None: scheduled: list[FakeTimer] = [] @@ -178,42 +271,99 @@ def fire(self) -> None: self.callback() controller = wintray._WindowsTrayController(mock=True, interval=60) - controller.active_panel_id = "classic" switched_to: list[str] = [] monkeypatch.setattr(controller, "switch_panel", switched_to.append) monkeypatch.setattr(threading, "Timer", FakeTimer) - controller.handle_panel_message("switch") + controller.handle_panel_message(json.dumps({"action": "switch_panel", "panel_id": "matrix"})) + controller.handle_panel_message(json.dumps({"action": "switch_panel", "panel_id": "win95"})) assert len(scheduled) == 1 assert scheduled[0].delay == 0.05 - assert switched_to == [] - - controller.active_panel_id = "matrix" scheduled[0].fire() - assert switched_to == ["win95"] + assert switched_to == ["matrix"] -def test_switch_panel_ignores_second_click_while_switch_is_pending( +def test_panel_menu_data_is_localized_and_reads_current_checks( monkeypatch: pytest.MonkeyPatch, ) -> None: - scheduled: list[object] = [] - - class FakeTimer: - def __init__(self, delay: float, callback: object) -> None: - scheduled.append((delay, callback)) + controller = wintray._WindowsTrayController(mock=True, interval=60) + controller.language = "en" + controller.active_panel_id = "matrix" + monkeypatch.setattr(wintray, "_hide_claude_enabled", lambda: True) + monkeypatch.setattr(wintray, "_hide_codex_enabled", lambda: False) + monkeypatch.setattr(wintray, "_hide_agy_enabled", lambda: True) + monkeypatch.setattr(win_login_item, "is_enabled", lambda: True) + monkeypatch.setattr(wintray, "_quota_notifications_enabled", lambda: False) + monkeypatch.setattr(wintray, "_session_resume_enabled", lambda: True) + monkeypatch.setattr(wintray, "_terse_mode_enabled", lambda: False) + + menu = controller._panel_menu_data() + + assert menu[0] == { + "i18nKey": "panel_ai_daily", + "label": "AI Update Daily", + "action": "open_ai_daily", + } + assert [entry.get("i18nKey", entry.get("type")) for entry in menu] == [ + "panel_ai_daily", + "separator", + "switch_panel", + "hide_sections_menu", + "separator", + "launch_at_login", + "quota_notifications_menu", + "separator", + "project_butler", + "terse_mode_menu", + "separator", + "refresh_now", + ] + panels = cast(list[dict[str, object]], menu[2]["children"]) + hidden_sections = cast(list[dict[str, object]], menu[3]["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 menu[8]["checked"] is True + assert menu[9]["checked"] is False - def start(self) -> None: - return None +@pytest.mark.parametrize( + ("payload", "method", "expected"), + [ + ({"action": "open_ai_daily"}, "open_ai_daily", ()), + ({"action": "reset_panel_position"}, "reset_panel_position", ()), + ({"action": "switch_panel", "panel_id": "matrix"}, "_schedule_panel_switch", ("matrix",)), + ( + {"action": "toggle_hide_section", "preference_key": "hide_codex_section"}, + "toggle_hide_section", + ("hide_codex_section",), + ), + ({"action": "refresh"}, "refresh", ()), + ({"action": "toggle_login"}, "toggle_login", ()), + ({"action": "toggle_quota_notifications"}, "toggle_quota_notifications", ()), + ({"action": "toggle_session_resume"}, "toggle_session_resume", ()), + ({"action": "toggle_terse_mode"}, "toggle_terse_mode", ()), + ({"action": "check_update"}, "check_update", ()), + ({"action": "quit"}, "quit", ()), + ], +) +def test_panel_menu_actions_dispatch_to_controller_methods( + monkeypatch: pytest.MonkeyPatch, + payload: dict[str, str], + method: str, + expected: tuple[str, ...], +) -> None: controller = wintray._WindowsTrayController(mock=True, interval=60) - monkeypatch.setattr(threading, "Timer", FakeTimer) + calls: list[tuple[str, ...]] = [] + monkeypatch.setattr(controller, method, lambda *args: calls.append(args)) - controller.handle_panel_message("switch") - controller.handle_panel_message("switch") + controller.handle_panel_message(json.dumps(payload)) - assert len(scheduled) == 1 + assert calls == [expected] @pytest.mark.parametrize("panel_id", ["matrix", "aquarium", "win95"]) @@ -288,6 +438,7 @@ def create_window(*args: object, **kwargs: object) -> object: ) return window + FakeMenu.SEPARATOR = object() # type: ignore[attr-defined] fake_pystray = SimpleNamespace(Icon=FakeIcon, Menu=FakeMenu, MenuItem=FakeMenuItem) fake_webview = SimpleNamespace( create_window=create_window, @@ -348,6 +499,74 @@ def test_show_panel_places_window_before_showing( assert calls == ["place", "show", "inject", "refresh"] +def test_hide_section_updates_preferences_and_visible_panel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + preferences: dict[str, object] = {} + saved: list[dict[str, object]] = [] + controller = wintray._WindowsTrayController(mock=True, interval=60) + controller.visible = True + injected: list[str] = [] + monkeypatch.setattr(wintray, "_load_preferences", lambda: preferences) + monkeypatch.setattr(wintray, "_save_preferences", lambda value: saved.append(dict(value))) + monkeypatch.setattr(wintray, "_hide_claude_enabled", lambda: True) + monkeypatch.setattr(wintray, "_hide_codex_enabled", lambda: False) + monkeypatch.setattr(wintray, "_hide_agy_enabled", lambda: False) + monkeypatch.setattr(controller, "inject_state", lambda: injected.append("state")) + + controller.toggle_hide_section("hide_claude_section") + + assert preferences == {"hide_claude_section": True} + assert saved == [preferences] + assert controller.latest_state.hide_claude is True + assert injected == ["state"] + + +def test_quota_notifications_use_pystray_notify_and_existing_i18n( + monkeypatch: pytest.MonkeyPatch, +) -> None: + controller = wintray._WindowsTrayController(mock=False, interval=60) + controller.language = "en" + notices: list[tuple[str, str]] = [] + controller.icon = SimpleNamespace( + notify=lambda message, title: notices.append((message, title)) + ) + monkeypatch.setattr(wintray, "_quota_notifications_enabled", lambda: True) + state = _state() + + controller._send_quota_notification( + NotificationEvent("warn", "claude_session", 90.0), state + ) + + assert notices == [("Claude Session is 25% used. Time to wrap up?", "🐾 Almost out")] + + +def test_session_hook_toggles_run_in_background_helpers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + + def record(name: str) -> int: + calls.append(name) + return 0 + + hooks = SimpleNamespace( + is_resume_enabled=lambda: False, + enable_session_resume=lambda: record("enable_resume"), + disable_session_resume=lambda: record("disable_resume"), + is_terse_mode_enabled=lambda: True, + enable_terse_mode=lambda: record("enable_terse"), + disable_terse_mode=lambda: record("disable_terse"), + ) + monkeypatch.setitem(sys.modules, "session_hooks", hooks) + controller = wintray._WindowsTrayController(mock=True, interval=60) + + controller._toggle_session_resume_in_background() + controller._toggle_terse_mode_in_background() + + assert calls == ["enable_resume", "disable_terse"] + + def test_run_app_bails_out_when_another_instance_holds_the_lock( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -396,8 +615,14 @@ def test_menu_actions_pass_real_pystray_signature_validation() -> None: active_panel_id="classic", switch_panel=lambda panel_id: None, show_panel=lambda: None, + reset_panel_position=lambda: None, refresh=lambda: None, toggle_login=lambda: None, + open_ai_daily=lambda: None, + toggle_hide_section=lambda key: None, + toggle_quota_notifications=lambda: None, + toggle_session_resume=lambda: None, + toggle_terse_mode=lambda: None, check_update=lambda: None, quit=lambda: None, ) diff --git a/usage_session_resume.py b/usage_session_resume.py index d5eeb330..934a4a38 100644 --- a/usage_session_resume.py +++ b/usage_session_resume.py @@ -44,10 +44,21 @@ from collections.abc import Mapping from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any +from typing import Any, cast __version__ = "1.6" + +def _configure_windows_utf8_output() -> None: + """Make SessionStart JSON UTF-8 when Claude Code reads a Windows pipe.""" + if os.name != "nt": + return + for stream in (sys.stdout, sys.stderr): + with contextlib.suppress(AttributeError, OSError, ValueError): + # Test runners and embedders may replace the TextIOWrapper streams. + cast(Any, stream).reconfigure(encoding="utf-8") + + PROMPT_SIDECAR = Path(os.path.expanduser("~/.claude/usage-resume-prompt.json")) DIAGNOSIS_SNAPSHOT = Path(os.path.expanduser("~/.claude/usage-diagnosis.json")) DIAGNOSIS_STATE = Path(os.path.expanduser("~/.claude/usage-diagnosis-state.json")) @@ -240,6 +251,7 @@ def main() -> int: + _configure_windows_utf8_output() try: payload = json.loads(sys.stdin.read() or "{}") except (json.JSONDecodeError, ValueError): diff --git a/usage_statusline.py b/usage_statusline.py index b019b901..f4c6d512 100644 --- a/usage_statusline.py +++ b/usage_statusline.py @@ -26,9 +26,20 @@ import sys import tempfile import time -from contextlib import contextmanager +from contextlib import contextmanager, suppress from datetime import datetime, timezone -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, cast + + +def _configure_windows_utf8_output() -> None: + """Make hook output UTF-8 when Claude Code reads a Windows pipe.""" + if os.name != "nt": + return + for stream in (sys.stdout, sys.stderr): + with suppress(AttributeError, OSError, ValueError): + # Test runners and embedders may replace the TextIOWrapper streams. + cast(Any, stream).reconfigure(encoding="utf-8") + _fcntl: Any = None _msvcrt: Any = None @@ -671,6 +682,7 @@ def render(data: Dict[str, Any], now: datetime) -> str: def main() -> None: + _configure_windows_utf8_output() try: raw = sys.stdin.read() except Exception as exc: diff --git a/usage_statusline_forwarder.py b/usage_statusline_forwarder.py index a6b9bea3..b80a5d14 100644 --- a/usage_statusline_forwarder.py +++ b/usage_statusline_forwarder.py @@ -10,11 +10,13 @@ from __future__ import annotations import concurrent.futures +import contextlib import glob import os import shutil import subprocess import sys +from typing import Any, cast __version__ = "1.0" TIMEOUT_SECONDS = 5 @@ -22,6 +24,16 @@ SELF_NAME = "usage-statusline-forwarder.py" +def _configure_windows_utf8_output() -> None: + """Make forwarded hook output UTF-8 when Claude Code reads a pipe.""" + if os.name != "nt": + return + for stream in (sys.stdout, sys.stderr): + with contextlib.suppress(AttributeError, OSError, ValueError): + # Test runners and embedders may replace the TextIOWrapper streams. + cast(Any, stream).reconfigure(encoding="utf-8") + + def _run_hook(py: str, hook: str, raw: str) -> str: try: result = subprocess.run( @@ -38,6 +50,7 @@ def _run_hook(py: str, hook: str, raw: str) -> str: def main() -> None: + _configure_windows_utf8_output() raw = sys.stdin.read() if not raw.strip(): return diff --git a/wintray.py b/wintray.py index ddd2c6fb..9285785e 100644 --- a/wintray.py +++ b/wintray.py @@ -29,6 +29,8 @@ _hide_claude_enabled, _hide_codex_enabled, _quota_card_order, + _quota_notification_thresholds, + _quota_notifications_enabled, ) from panels.payload import _load_panel_html, _state_payload from prefs import _load_preferences, _save_preferences @@ -36,6 +38,7 @@ from statusline_settings import _statusline_enabled, _toggle_statusline_settings from usage_client import ClaudeUsageClient, PollState from usage_lang import detect_lang +from usage_notifications import NotificationEvent, QuotaNotifier from usage_rate import UsageRateTracker if TYPE_CHECKING: @@ -77,7 +80,205 @@ window.webkit.messageHandlers.usage = { postMessage: function(message) { return window.pywebview.api.postMessage(message); } }; + +// The panel assets are shared with macOS. On Windows, intercept their +// built-in switch button and provide the equivalent of the native menu here. +(function() { + var menuRoot; + + function closeMenu() { + if (menuRoot) { + menuRoot.remove(); + menuRoot = null; + } + } + + function post(action, extra) { + var message = Object.assign({ action: action }, extra || {}); + return Promise.resolve( + window.webkit.messageHandlers.usage.postMessage(JSON.stringify(message)) + ); + } + + function menuItem(item) { + if (item.type === 'separator') { + var separator = document.createElement('div'); + separator.className = 'usage-panel-menu-separator'; + separator.setAttribute('role', 'separator'); + return separator; + } + if (item.children) { + var group = document.createElement('div'); + group.className = 'usage-panel-menu-accordion'; + var row = document.createElement('button'); + row.type = 'button'; + row.className = 'usage-panel-menu-item usage-panel-menu-parent'; + row.setAttribute('role', 'menuitem'); + row.setAttribute('aria-expanded', 'false'); + row.textContent = item.label + ' ›'; + var submenu = document.createElement('div'); + submenu.className = 'usage-panel-menu-submenu'; + submenu.setAttribute('role', 'menu'); + item.children.forEach(function(child) { submenu.appendChild(menuItem(child)); }); + row.addEventListener('click', function() { + var expanded = row.getAttribute('aria-expanded') === 'true'; + row.setAttribute('aria-expanded', String(!expanded)); + row.textContent = item.label + (!expanded ? ' ˅' : ' ›'); + submenu.hidden = expanded; + }); + submenu.hidden = true; + group.appendChild(row); + group.appendChild(submenu); + return group; + } + var row = document.createElement('button'); + row.type = 'button'; + row.className = 'usage-panel-menu-item'; + row.setAttribute('role', 'menuitemcheckbox'); + 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; + post(item.action, extra); + closeMenu(); + }); + return row; + } + + function showMenu(items) { + closeMenu(); + menuRoot = document.createElement('div'); + menuRoot.className = 'usage-panel-menu-backdrop'; + menuRoot.setAttribute('aria-hidden', 'false'); + var menu = document.createElement('div'); + menu.className = 'usage-panel-menu'; + menu.setAttribute('role', 'menu'); + items.forEach(function(item) { menu.appendChild(menuItem(item)); }); + menuRoot.appendChild(menu); + menuRoot.addEventListener('click', function(event) { + if (event.target === menuRoot) closeMenu(); + }); + document.body.appendChild(menuRoot); + } + + document.addEventListener('click', function(event) { + var button = event.target.closest && event.target.closest('[data-action="switch"]'); + if (!button) return; + event.preventDefault(); + event.stopImmediatePropagation(); + post('open_menu').then(function(items) { + if (Array.isArray(items)) showMenu(items); + }); + }, true); + document.addEventListener('keydown', function(event) { + if (event.key === 'Escape') closeMenu(); + }); +})(); + +// Panel assets register their card reorder handler in the bubbling phase. This +// earlier capture listener turns their empty card area into a native drag +// region without changing the shared macOS HTML. Add the class only after +// excluding controls, so pywebview never treats a button click as a window drag. +document.addEventListener('pointerdown', function(event) { + var target = event.target; + var card = target && target.closest && target.closest( + '[data-card="claude"], [data-card="codex"], [data-card="agy"]' + ); + var interactive = target && target.closest && target.closest( + 'button, a, input, select, textarea, label, summary, [contenteditable], ' + + '[role="button"], .codex-stale-info, .stale-info' + ); + if (!card || event.button !== 0 || interactive) return; + card.classList.add('pywebview-drag-region', 'usage-card-window-dragging'); + var clearDragRegion = function() { + card.classList.remove('pywebview-drag-region', 'usage-card-window-dragging'); + document.removeEventListener('pointerup', clearDragRegion, true); + document.removeEventListener('pointercancel', clearDragRegion, true); + }; + document.addEventListener('pointerup', clearDragRegion, true); + document.addEventListener('pointercancel', clearDragRegion, true); + event.stopImmediatePropagation(); +}, true); + +// Keep the native drag target deliberately small so it remains distinct from +// normal panel interaction. +document.addEventListener('DOMContentLoaded', function() { + var handle = document.createElement('div'); + handle.className = 'usage-window-drag-handle pywebview-drag-region'; + handle.setAttribute('aria-hidden', 'true'); + document.body.appendChild(handle); +}); + """.strip() @@ -239,8 +440,10 @@ def __init__(self, controller: _WindowsTrayController) -> None: # WinForms window graph) recurses forever. self._controller = controller - def postMessage(self, message: object) -> None: # noqa: N802 - JavaScript contract - self._controller.handle_panel_message(message) + def postMessage( # noqa: N802 - JavaScript contract + self, message: object + ) -> list[dict[str, object]] | None: + return self._controller.handle_panel_message(message) class _WindowsTrayController: @@ -261,8 +464,10 @@ def __init__(self, mock: bool, interval: int) -> None: self.icon: Any = None self.window: Any = None self.visible = False + self._positioned_this_show = False self.stopping = threading.Event() self.refresh_lock = threading.Lock() + self._quota_notifier = QuotaNotifier(_quota_notification_thresholds()) def _empty_state(self) -> menubar_state.PopoverState: missing = menubar_state._missing_row @@ -323,9 +528,10 @@ def on_loaded(self) -> None: self._place_window() self.inject_state() - def _place_window(self) -> None: - if os.name != "nt" or self.window is None: - return + def _working_area(self) -> tuple[int, int, int, int] | None: + """Return the primary monitor work area (without taskbar).""" + if os.name != "nt": + return None import ctypes class Rect(ctypes.Structure): @@ -340,12 +546,89 @@ class Rect(ctypes.Structure): library_name = "windll" user32: Any = getattr(ctypes, library_name).user32 if user32.SystemParametersInfoW(0x0030, 0, ctypes.byref(rect), 0): - height = min(self.panel_height(), max(640, rect.bottom - rect.top - 24)) - self.window.resize(PANEL_WIDTH, height) - self.window.move( - max(rect.left + 12, rect.right - PANEL_WIDTH - 12), - max(rect.top + 12, rect.bottom - height - 12), + return (rect.left, rect.top, rect.right, rect.bottom) + return None + + def _saved_window_position(self) -> tuple[int, int] | None: + value = _load_preferences().get("usage.windowPosition") + if not isinstance(value, dict): + return None + x, y = value.get("x"), value.get("y") + if isinstance(x, bool) or isinstance(y, bool): + return None + if not isinstance(x, (int, float)) or not isinstance(y, (int, float)): + return None + return (int(x), int(y)) + + def _current_window_position(self) -> tuple[int, int] | None: + if self.window is None: + return None + try: + x, y = self.window.x, self.window.y + except (AttributeError, TypeError, ValueError): + return None + if isinstance(x, bool) or isinstance(y, bool): + return None + if not isinstance(x, (int, float)) or not isinstance(y, (int, float)): + return None + return (int(x), int(y)) + + @staticmethod + def _clamp_window_position( + position: tuple[int, int], work_area: tuple[int, int, int, int], height: int + ) -> tuple[int, int]: + left, top, right, bottom = work_area + return ( + min(max(position[0], left + 12), max(left + 12, right - PANEL_WIDTH - 12)), + min(max(position[1], top + 12), max(top + 12, bottom - height - 12)), + ) + + @staticmethod + def _default_window_position( + work_area: tuple[int, int, int, int], height: int + ) -> tuple[int, int]: + left, top, right, bottom = work_area + return (max(left + 12, right - PANEL_WIDTH - 12), max(top + 12, bottom - height - 12)) + + def _place_window(self, *, force_default: bool = False) -> None: + if self.window is None: + return + work_area = self._working_area() + if work_area is None: + return + left, top, right, bottom = work_area + height = min(self.panel_height(), max(640, bottom - top - 24)) + self.window.resize(PANEL_WIDTH, height) + if force_default: + position = self._default_window_position(work_area, height) + elif self._positioned_this_show: + current_position = self._current_window_position() + position = ( + current_position + or self._saved_window_position() + or self._default_window_position(work_area, height) ) + else: + position = self._saved_window_position() or self._default_window_position( + work_area, height + ) + self.window.move(*self._clamp_window_position(position, work_area, height)) + self._positioned_this_show = True + + def _save_window_position(self) -> None: + position = self._current_window_position() + if position is None: + return + preferences = _load_preferences() + preferences["usage.windowPosition"] = {"x": position[0], "y": position[1]} + _save_preferences(preferences) + + def reset_panel_position(self, _icon: Any = None, _item: Any = None) -> None: + preferences = _load_preferences() + preferences.pop("usage.windowPosition", None) + _save_preferences(preferences) + if self.visible: + self._place_window(force_default=True) def _poll_loop(self) -> None: while not self.stopping.wait( @@ -361,6 +644,7 @@ def refresh(self) -> None: def _refresh_worker(self) -> None: try: self.latest_state = self._build_state() + self._process_quota_notifications(self.latest_state) self._update_tray() if self.visible: self.inject_state() @@ -458,7 +742,9 @@ def inject_state(self) -> None: def show_panel(self, _icon: Any = None, _item: Any = None) -> None: if self.visible: + self._save_window_position() self.visible = False + self._positioned_this_show = False self.window.hide() return self.visible = True @@ -477,15 +763,178 @@ def switch_panel(self, panel_id: str) -> None: self.latest_state.card_order = _quota_card_order() self.window.load_html(panel_html(self.panel_filename())) - def _deferred_switch_panel(self) -> None: + def _deferred_switch_panel(self, panel_id: str) -> None: self._switch_pending = False - ids = [panel[0] for panel in available_panels()] - next_panel_id = ids[(ids.index(self.active_panel_id) + 1) % len(ids)] - self.switch_panel(next_panel_id) + self.switch_panel(panel_id) + + def _schedule_panel_switch(self, panel_id: str) -> None: + if self._switch_pending or panel_id not in {panel[0] for panel in available_panels()}: + return + self._switch_pending = True + # postMessage is a pywebview promise. Reloading the document before + # that promise resolves destroys its callback and can leave the Edge + # WebView as a blank white window. Keep the existing short deferral, + # but now reload the panel explicitly chosen from the HTML menu. + threading.Timer(0.05, lambda: self._deferred_switch_panel(panel_id)).start() + + def _panel_menu_data(self) -> list[dict[str, object]]: + """Return fresh, localized data for the HTML panel menu.""" + + def item(key: str, action: str, **extra: object) -> dict[str, object]: + return { + "i18nKey": key, + "label": _t(self.language, key), + "action": action, + **extra, + } + + panels = [ + item( + key, + "switch_panel", + panelId=panel_id, + checked=self.active_panel_id == panel_id, + ) + for panel_id, key, _filename in available_panels() + ] + hidden_sections = [ + item( + "claude_name", + "toggle_hide_section", + preferenceKey="hide_claude_section", + checked=_hide_claude_enabled(), + ), + item( + "codex_name", + "toggle_hide_section", + preferenceKey="hide_codex_section", + checked=_hide_codex_enabled(), + ), + item( + "agy_name", + "toggle_hide_section", + preferenceKey="hide_agy_section", + checked=_hide_agy_enabled(), + ), + ] + return [ + item("panel_ai_daily", "open_ai_daily"), + {"type": "separator"}, + item("switch_panel", "", children=panels), + item("hide_sections_menu", "", children=hidden_sections), + {"type": "separator"}, + item("launch_at_login", "toggle_login", checked=win_login_item.is_enabled()), + item( + "quota_notifications_menu", + "toggle_quota_notifications", + checked=_quota_notifications_enabled(), + ), + {"type": "separator"}, + item("project_butler", "toggle_session_resume", checked=_session_resume_enabled()), + item("terse_mode_menu", "toggle_terse_mode", checked=_terse_mode_enabled()), + {"type": "separator"}, + item("refresh_now", "refresh"), + ] def toggle_login(self, _icon: Any = None, _item: Any = None) -> None: win_login_item.disable() if win_login_item.is_enabled() else win_login_item.enable() + def open_ai_daily(self, _icon: Any = None, _item: Any = None) -> None: + webbrowser.open("https://aqua5230.github.io/ai-updates/") + + def toggle_hide_section(self, preference_key: str) -> None: + preferences = _load_preferences() + preferences[preference_key] = preferences.get(preference_key) is not True + _save_preferences(preferences) + self.latest_state.hide_claude = _hide_claude_enabled() + self.latest_state.hide_codex = _hide_codex_enabled() + self.latest_state.hide_agy = _hide_agy_enabled() + if self.visible: + self.inject_state() + + def toggle_quota_notifications(self, _icon: Any = None, _item: Any = None) -> None: + preferences = _load_preferences() + preferences["quota_notifications"] = not _quota_notifications_enabled(preferences) + _save_preferences(preferences) + + def toggle_session_resume(self, _icon: Any = None, _item: Any = None) -> None: + threading.Thread(target=self._toggle_session_resume_in_background, daemon=True).start() + + def _toggle_session_resume_in_background(self) -> None: + import session_hooks + + try: + if session_hooks.is_resume_enabled(): + session_hooks.disable_session_resume() + else: + session_hooks.enable_session_resume() + except Exception: + if os.environ.get("USAGE_DEBUG") == "1": + logger.warning("toggle session resume failed", exc_info=True) + + def toggle_terse_mode(self, _icon: Any = None, _item: Any = None) -> None: + threading.Thread(target=self._toggle_terse_mode_in_background, daemon=True).start() + + def _toggle_terse_mode_in_background(self) -> None: + import session_hooks + + try: + if session_hooks.is_terse_mode_enabled(): + session_hooks.disable_terse_mode() + else: + session_hooks.enable_terse_mode() + except Exception: + if os.environ.get("USAGE_DEBUG") == "1": + logger.warning("toggle terse mode failed", exc_info=True) + + def _process_quota_notifications(self, state: menubar_state.PopoverState) -> None: + try: + events = self._quota_notifier.update( + { + "claude_session": ( + state.claude_session.percent, + state.claude_session.available, + ), + "claude_weekly": ( + state.claude_weekly.percent, + state.claude_weekly.available, + ), + "codex_session": (state.codex_session.percent, state.codex_session.available), + "codex_weekly": (state.codex_weekly.percent, state.codex_weekly.available), + } + ) + if _quota_notifications_enabled() and not self.mock: + for event in events: + self._send_quota_notification(event, state) + except Exception: + if os.environ.get("USAGE_DEBUG") == "1": + logger.warning("Windows quota notification processing failed", exc_info=True) + + def _send_quota_notification( + self, event: NotificationEvent, state: menubar_state.PopoverState + ) -> None: + if self.icon is None or not hasattr(self.icon, "notify"): + return + rows = { + "claude_session": state.claude_session, + "claude_weekly": state.claude_weekly, + "codex_session": state.codex_session, + "codex_weekly": state.codex_weekly, + } + row = rows[event.channel] + scope = row.title or _t( + self.language, "session_label" if event.channel.endswith("_session") else "weekly_label" + ) + message = _t( + self.language, + f"notif_{event.kind}_body", + tool="Claude" if event.channel.startswith("claude_") else "Codex", + scope=scope, + pct=f"{round(row.percent or event.threshold or 0.0):g}", + reset=row.reset_text, + ) + self.icon.notify(message, _t(self.language, f"notif_{event.kind}_title")) + def check_update(self, _icon: Any = None, _item: Any = None) -> None: def worker() -> None: result = update_checker.check_latest_release_result(_current_version()) @@ -511,15 +960,18 @@ def _message_box(self, text: str, *, style: int = 0x40) -> int: windll: Any = getattr(ctypes, library_name) return int(windll.user32.MessageBoxW(0, text, "usage", style)) - def handle_panel_message(self, message: object) -> None: + def handle_panel_message(self, message: object) -> list[dict[str, object]] | None: payload: object = message if isinstance(message, str) and message.startswith("{"): try: payload = json.loads(message) except ValueError: - return + return None if isinstance(payload, dict): - if payload.get("action") == "set_card_order": + action = payload.get("action") + if action == "open_menu": + return self._panel_menu_data() + if action == "set_card_order": order = payload.get("order") if ( isinstance(order, list) @@ -530,24 +982,46 @@ def handle_panel_message(self, message: object) -> None: preferences = _load_preferences() preferences["quota_card_order"] = order _save_preferences(preferences) - return + elif action == "switch_panel": + panel_id = payload.get("panel_id") + if isinstance(panel_id, str): + self._schedule_panel_switch(panel_id) + elif action == "toggle_hide_section": + preference_key = payload.get("preference_key") + if preference_key in { + "hide_claude_section", + "hide_codex_section", + "hide_agy_section", + }: + self.toggle_hide_section(preference_key) + elif action == "open_ai_daily": + self.open_ai_daily() + elif action == "reset_panel_position": + self.reset_panel_position() + elif action == "refresh": + self.refresh() + elif action == "toggle_login": + self.toggle_login() + elif action == "toggle_quota_notifications": + self.toggle_quota_notifications() + elif action == "toggle_session_resume": + self.toggle_session_resume() + elif action == "toggle_terse_mode": + self.toggle_terse_mode() + elif action == "check_update": + self.check_update() + elif action == "quit": + self.quit() + return None action = str(payload) if action == "refresh": self.refresh() elif action == "quit": self.quit() elif action == "switch": - if self._switch_pending: - return - self._switch_pending = True - # postMessage is a pywebview promise. Reloading the document before - # that promise resolves destroys its callback and can leave the - # Edge WebView as a blank white window. Defer the reload until the - # bridge call has returned to JavaScript. This empirical delay is - # not a synchronization guarantee. If slow machines still show a - # blank window, JavaScript must call a second API after postMessage's - # promise resolves to trigger the reload; do not increase this delay. - threading.Timer(0.05, self._deferred_switch_panel).start() + # Older panel assets post this action directly. Return menu data + # instead of cycling themes so the bridge remains forwards-safe. + return self._panel_menu_data() elif action in {"toggle_statusline", "toggle-statusline"}: threading.Thread(target=self._toggle_statusline, daemon=True).start() elif action == "install": @@ -561,6 +1035,7 @@ def handle_panel_message(self, message: object) -> None: args=(str(project_range or "30d"),), daemon=True, ).start() + return None def _toggle_statusline(self) -> None: _toggle_statusline_settings() @@ -607,18 +1082,79 @@ def _menu(controller: _WindowsTrayController) -> Any: ) return pystray.Menu( pystray.MenuItem("Open", controller.show_panel, default=True, visible=False), + pystray.MenuItem(_t(controller.language, "panel_ai_daily"), controller.open_ai_daily), + pystray.MenuItem( + _t(controller.language, "reset_panel_position"), controller.reset_panel_position + ), + pystray.Menu.SEPARATOR, pystray.MenuItem(_t(controller.language, "switch_panel"), pystray.Menu(*panel_items)), + pystray.MenuItem( + _t(controller.language, "hide_sections_menu"), + pystray.Menu( + pystray.MenuItem( + _t(controller.language, "claude_name"), + lambda _icon, _item: controller.toggle_hide_section("hide_claude_section"), + checked=lambda _item: _hide_claude_enabled(), + ), + pystray.MenuItem( + _t(controller.language, "codex_name"), + lambda _icon, _item: controller.toggle_hide_section("hide_codex_section"), + checked=lambda _item: _hide_codex_enabled(), + ), + pystray.MenuItem( + _t(controller.language, "agy_name"), + lambda _icon, _item: controller.toggle_hide_section("hide_agy_section"), + checked=lambda _item: _hide_agy_enabled(), + ), + ), + ), + pystray.Menu.SEPARATOR, pystray.MenuItem(_t(controller.language, "refresh_now"), lambda i, x: controller.refresh()), pystray.MenuItem( _t(controller.language, "launch_at_login"), controller.toggle_login, checked=lambda _item: win_login_item.is_enabled(), ), + pystray.MenuItem( + _t(controller.language, "quota_notifications_menu"), + controller.toggle_quota_notifications, + checked=lambda _item: _quota_notifications_enabled(), + ), + pystray.Menu.SEPARATOR, + pystray.MenuItem( + _t(controller.language, "project_butler"), + controller.toggle_session_resume, + checked=lambda _item: _session_resume_enabled(), + ), + pystray.MenuItem( + _t(controller.language, "terse_mode_menu"), + controller.toggle_terse_mode, + checked=lambda _item: _terse_mode_enabled(), + ), + pystray.Menu.SEPARATOR, pystray.MenuItem(_t(controller.language, "check_update"), controller.check_update), pystray.MenuItem(_t(controller.language, "quit"), controller.quit), ) +def _session_resume_enabled() -> bool: + try: + import session_hooks + + return session_hooks.is_resume_enabled() + except Exception: + return False + + +def _terse_mode_enabled() -> bool: + try: + import session_hooks + + return session_hooks.is_terse_mode_enabled() + except Exception: + return False + + _SINGLE_INSTANCE_MUTEX = "usage-windows-tray-single-instance" _ERROR_ALREADY_EXISTS = 183 _single_instance_handle: int | None = None