Skip to content
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

### 修正
Expand Down
5 changes: 5 additions & 0 deletions i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "🐾 快用完囉",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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": "🐾 快用完啦",
Expand Down Expand Up @@ -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": "🐾 もうすぐ使い切り",
Expand Down Expand Up @@ -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": "🐾 거의 다 썼어요",
Expand Down
4 changes: 4 additions & 0 deletions session_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
14 changes: 13 additions & 1 deletion setup_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand Down
22 changes: 22 additions & 0 deletions tests/test_session_resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand Down
21 changes: 21 additions & 0 deletions tests/test_setup_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import tomllib
from collections.abc import Callable
from pathlib import Path
from types import SimpleNamespace

import pytest

Expand All @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions tests/test_usage_statusline.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,42 @@
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from types import SimpleNamespace
from typing import Any

import pytest

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,
Expand Down
21 changes: 21 additions & 0 deletions tests/test_usage_statusline_forwarder.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,34 @@
import subprocess
import sys
from importlib import import_module
from types import SimpleNamespace
from typing import Any

import pytest

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],
Expand Down
Loading
Loading