From 3d7c7cf3f5af51e9fd7bc3135418c74217659cf2 Mon Sep 17 00:00:00 2001 From: Loll <44865998+aqua5230@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:09:48 +0800 Subject: [PATCH 1/3] fix: use ASCII-only Python path in Windows statusLine hook command Claude Code on Windows fails to spawn statusLine commands whose path contains non-ASCII characters. setup_hook now prefers an all-ASCII python.exe (falling back to system PATH when the venv path is not ASCII) and --setup migrates existing non-ASCII commands. Co-Authored-By: Claude Fable 5 --- setup_hook.py | 26 ++++++++++++++++++----- tests/test_setup_hook.py | 46 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/setup_hook.py b/setup_hook.py index 4795374..ee53c29 100644 --- a/setup_hook.py +++ b/setup_hook.py @@ -132,9 +132,19 @@ def _statusline_command_target_exists() -> bool: def _find_system_python() -> str: if sys.platform == "win32": executable = sys.executable - if executable and not getattr(sys, "frozen", False): + if ( + executable + and not getattr(sys, "frozen", False) + and _is_ascii_path(executable) + ): return executable - return shutil.which("python") or "python" + # Claude Code can fail to spawn a command containing non-ASCII paths + # on Windows. The hook is stdlib-only, so an ASCII-path Python from + # PATH (or the Windows launcher) is preferable to this app's venv. + for candidate in (shutil.which("python"), shutil.which("py")): + if candidate and _is_ascii_path(candidate): + return candidate + return "python" if os.path.exists("/usr/bin/python3"): return "/usr/bin/python3" executable = sys.executable @@ -143,6 +153,10 @@ def _find_system_python() -> str: return shutil.which("python3") or "python3" +def _is_ascii_path(value: str) -> bool: + return value.isascii() + + def _shell_arg(value: str) -> str: if sys.platform == "win32": # Claude Code runs statusLine commands through Git Bash when it is @@ -165,7 +179,7 @@ def _uses_bundled_app_python(command: str) -> bool: def _migrate_windows_statusline_command_if_needed( settings: dict[str, Any] | None = None, ) -> None: - """Replace legacy backslash paths in usage-owned Windows statusLine commands.""" + """Repair Windows usage commands with backslash or non-ASCII paths.""" if sys.platform != "win32": return data = _load_settings() if settings is None else settings @@ -173,7 +187,7 @@ def _migrate_windows_statusline_command_if_needed( if not isinstance(sl, dict): return command = sl.get("command") - if not isinstance(command, str) or "\\" not in command: + if not isinstance(command, str) or ("\\" not in command and command.isascii()): return if "usage-statusline-forwarder" in command: new_command = _forwarder_command() @@ -185,7 +199,9 @@ def _migrate_windows_statusline_command_if_needed( return sl["command"] = new_command _save_settings(data) - _append_hook_repair_log("migrate_windows_statusline", "backslash paths -> forward slashes") + _append_hook_repair_log( + "migrate_windows_statusline", "backslash/non-ASCII paths -> ASCII forward-slash command" + ) def _append_hook_repair_log(action: str, detail: str) -> None: diff --git a/tests/test_setup_hook.py b/tests/test_setup_hook.py index 937cabe..6191ff2 100644 --- a/tests/test_setup_hook.py +++ b/tests/test_setup_hook.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +import shutil import sys import tomllib from collections.abc import Callable @@ -220,6 +221,20 @@ def test_find_system_python_uses_current_interpreter_on_windows( assert setup_hook._find_system_python() == r"C:\\Program Files\\Python\\python.exe" +def test_find_system_python_avoids_non_ascii_windows_interpreter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(sys, "executable", r"C:\\專案\\usage\\.venv\\Scripts\\python.exe") + monkeypatch.setattr( + shutil, + "which", + lambda name: r"C:\\Program Files\\Python\\python.exe" if name == "python" else None, + ) + + assert setup_hook._find_system_python() == r"C:\\Program Files\\Python\\python.exe" + + def test_windows_hook_commands_use_double_quotes(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(sys, "platform", "win32") monkeypatch.setattr( @@ -270,6 +285,37 @@ def test_windows_statusline_migration_rewrites_legacy_backslash_paths( assert data["usage"]["selfHealLog"][-1]["action"] == "migrate_windows_statusline" +def test_windows_statusline_migration_rewrites_non_ascii_interpreter( + monkeypatch: pytest.MonkeyPatch, + setup_paths: SetupHookPaths, +) -> None: + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr( + setup_hook, "_find_system_python", lambda: r"C:\\Program Files\\Python\\python.exe" + ) + setup_paths.settings.write_text( + json.dumps( + { + "statusLine": { + "type": "command", + "command": ( + r"C:/Users/USER/Desktop/GitHub專案/usage/.venv/Scripts/python.exe " + f"{setup_paths.hook_target}" + ), + } + } + ), + encoding="utf-8", + ) + + setup_hook._migrate_windows_statusline_command_if_needed() + + data = json.loads(setup_paths.settings.read_text(encoding="utf-8")) + command = data["statusLine"]["command"] + assert command == expected_statusline_command(setup_paths.hook_target) + assert command.isascii() + + def test_setup_codex_replaces_only_tui_status_line( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: From 907fe5c4dc9277b0a58726054e561954bfaea7ad Mon Sep 17 00:00:00 2001 From: Loll <44865998+aqua5230@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:10:16 +0800 Subject: [PATCH 2/3] fix: read hook stdin as UTF-8 so CJK cwd no longer breaks the payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows decodes piped stdin with the locale codepage (cp950 on zh-TW); Claude Code sends the session JSON as UTF-8, so a cwd like 'GitHub專案' turned into mojibake, JSON parsing failed, and usage-status.json was never written. All five hook scripts now read stdin via sys.stdin.buffer and decode UTF-8 explicitly; the forwarder also pins encoding=utf-8 on its subprocess fan-out. Co-Authored-By: Claude Fable 5 --- tests/test_session_resume.py | 22 +++++++++++++ tests/test_usage_statusline.py | 20 ++++++++++++ tests/test_usage_statusline_forwarder.py | 41 ++++++++++++++++++++++-- tests/test_usage_terse_mode.py | 18 +++++++++++ tests/test_usage_terse_reminder.py | 18 +++++++++++ usage_session_resume.py | 9 +++++- usage_statusline.py | 9 +++++- usage_statusline_forwarder.py | 11 ++++++- usage_terse_mode.py | 12 +++++-- usage_terse_reminder.py | 12 +++++-- 10 files changed, 162 insertions(+), 10 deletions(-) diff --git a/tests/test_session_resume.py b/tests/test_session_resume.py index 45c3e6c..1dd36d9 100644 --- a/tests/test_session_resume.py +++ b/tests/test_session_resume.py @@ -6,6 +6,7 @@ from __future__ import annotations +import io import json import os import sys @@ -549,6 +550,27 @@ def test_main_emits_greeting_when_no_progress( assert out["hookSpecificOutput"]["additionalContext"] == "GREETING::" +def test_main_reads_utf8_bytes_when_stdin_uses_cp950( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setenv("LANG", "en_US.UTF-8") + _sidecar(tmp_path, monkeypatch) + project = _project_dir(tmp_path) + current = project / "current.jsonl" + current.write_text("", encoding="utf-8") + payload = json.dumps( + {"transcript_path": str(current), "cwd": r"C:\\Users\\USER\\Desktop\\GitHub專案\\usage"}, + ensure_ascii=False, + ) + monkeypatch.setattr( + sys, "stdin", io.TextIOWrapper(io.BytesIO(payload.encode("utf-8")), encoding="cp950") + ) + + assert mod.main() == 0 + out = json.loads(capsys.readouterr().out) + assert out["hookSpecificOutput"]["hookEventName"] == "SessionStart" + + def test_extract_commit_title_handles_heredoc_forms() -> None: # `git commit -F - <<'EOF'` has no `cat` prefix — the most common form, previously missed. assert ( diff --git a/tests/test_usage_statusline.py b/tests/test_usage_statusline.py index 4e4f54f..b8100ac 100644 --- a/tests/test_usage_statusline.py +++ b/tests/test_usage_statusline.py @@ -411,6 +411,26 @@ def test_main_writes_valid_json_object( assert capsys.readouterr().out == "usage\n" +def test_main_reads_utf8_bytes_when_stdin_uses_cp950( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + status_file = tmp_path / "usage-status.json" + payload = {"cwd": r"C:\\Users\\USER\\Desktop\\GitHub專案\\usage"} + stdin = io.TextIOWrapper( + io.BytesIO(json.dumps(payload, ensure_ascii=False).encode("utf-8")), encoding="cp950" + ) + monkeypatch.setattr(usage_statusline, "STATUS_FILE", str(status_file)) + monkeypatch.setattr(usage_statusline, "LOCK_FILE", str(tmp_path / "usage-status.lock")) + monkeypatch.setattr(sys, "stdin", stdin) + + usage_statusline.main() + + assert json.loads(status_file.read_text(encoding="utf-8"))["cwd"] == payload["cwd"] + assert capsys.readouterr().out == "usage\n" + + def test_main_returns_when_stdin_read_raises( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tests/test_usage_statusline_forwarder.py b/tests/test_usage_statusline_forwarder.py index 8cc9ad7..0bc523b 100644 --- a/tests/test_usage_statusline_forwarder.py +++ b/tests/test_usage_statusline_forwarder.py @@ -7,6 +7,7 @@ from __future__ import annotations import io +import json import subprocess import sys from importlib import import_module @@ -54,11 +55,15 @@ def fake_run( *, input: str, text: bool, + encoding: str, + errors: str, check: bool, capture_output: bool, timeout: int, ) -> subprocess.CompletedProcess[str]: assert text is True + assert encoding == "utf-8" + assert errors == "replace" assert check is False assert capture_output is True calls.append((cmd, input, timeout)) @@ -77,6 +82,30 @@ def fake_run( assert capsys.readouterr().out == "/tmp/claude-statusline.py\n/tmp/usage-statusline.py\n" +def test_main_reads_utf8_bytes_when_stdin_uses_cp950( + monkeypatch: pytest.MonkeyPatch, +) -> None: + raw_values: list[str] = [] + payload = json.dumps( + {"cwd": r"C:\\Users\\USER\\Desktop\\GitHub專案\\usage"}, ensure_ascii=False + ) + stdin = io.TextIOWrapper(io.BytesIO(payload.encode("utf-8")), encoding="cp950") + + def fake_run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + raw_values.append(kwargs["input"]) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(sys, "stdin", stdin) + monkeypatch.setattr( + usage_statusline_forwarder.glob, "glob", lambda pattern: ["/tmp/x-statusline.py"] + ) + monkeypatch.setattr(usage_statusline_forwarder.subprocess, "run", fake_run) + + usage_statusline_forwarder.main() + + assert raw_values == [payload] + + def test_timeout_hook_does_not_block_later_hooks( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -89,11 +118,13 @@ def fake_run( *, input: str, text: bool, + encoding: str, + errors: str, check: bool, capture_output: bool, timeout: int, ) -> subprocess.CompletedProcess[str]: - _ = input, text, check, capture_output + _ = input, text, encoding, errors, check, capture_output calls.append(cmd[1]) if cmd[1] == "/tmp/aaa-slow-statusline.py": raise subprocess.TimeoutExpired(cmd, timeout) @@ -120,11 +151,13 @@ def fake_run( *, input: str, text: bool, + encoding: str, + errors: str, check: bool, capture_output: bool, timeout: int, ) -> subprocess.CompletedProcess[str]: - _ = input, text, check, capture_output, timeout + _ = input, text, encoding, errors, check, capture_output, timeout if cmd[1] == "/tmp/fail-statusline.py": return subprocess.CompletedProcess(cmd, 1, stdout="failed output\n", stderr="boom") return subprocess.CompletedProcess(cmd, 0, stdout="ok output\n", stderr="") @@ -149,11 +182,13 @@ def fake_run( *, input: str, text: bool, + encoding: str, + errors: str, check: bool, capture_output: bool, timeout: int, ) -> subprocess.CompletedProcess[str]: - _ = input, text, check, capture_output, timeout + _ = input, text, encoding, errors, check, capture_output, timeout if cmd[1] == "/tmp/bad-statusline.py": raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") return subprocess.CompletedProcess(cmd, 0, stdout="ok output\n", stderr="") diff --git a/tests/test_usage_terse_mode.py b/tests/test_usage_terse_mode.py index 33f617c..d988c46 100644 --- a/tests/test_usage_terse_mode.py +++ b/tests/test_usage_terse_mode.py @@ -6,6 +6,7 @@ from __future__ import annotations +import io import json from pathlib import Path @@ -74,6 +75,23 @@ def test_main_uses_detected_language( assert out["hookSpecificOutput"]["additionalContext"] == "精簡::繁中" +def test_main_reads_utf8_bytes_when_stdin_uses_cp950( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setenv("LANG", "en_US.UTF-8") + _sidecar(tmp_path, monkeypatch) + payload = json.dumps( + {"cwd": r"C:\\Users\\USER\\Desktop\\GitHub專案\\usage"}, ensure_ascii=False + ) + monkeypatch.setattr( + "sys.stdin", io.TextIOWrapper(io.BytesIO(payload.encode("utf-8")), encoding="cp950") + ) + + assert mod.main() == 0 + out = json.loads(capsys.readouterr().out) + assert out["hookSpecificOutput"]["additionalContext"] == "TERSE::EN" + + def test_main_is_silent_on_invalid_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/test_usage_terse_reminder.py b/tests/test_usage_terse_reminder.py index 11bda9f..e150059 100644 --- a/tests/test_usage_terse_reminder.py +++ b/tests/test_usage_terse_reminder.py @@ -6,6 +6,7 @@ from __future__ import annotations +import io import json from pathlib import Path @@ -76,6 +77,23 @@ def test_main_uses_detected_language( assert out["hookSpecificOutput"]["additionalContext"] == "提醒::繁中" +def test_main_reads_utf8_bytes_when_stdin_uses_cp950( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setenv("LANG", "en_US.UTF-8") + _sidecar(tmp_path, monkeypatch) + payload = json.dumps( + {"cwd": r"C:\\Users\\USER\\Desktop\\GitHub專案\\usage"}, ensure_ascii=False + ) + monkeypatch.setattr( + "sys.stdin", io.TextIOWrapper(io.BytesIO(payload.encode("utf-8")), encoding="cp950") + ) + + assert mod.main() == 0 + out = json.loads(capsys.readouterr().out) + assert out["hookSpecificOutput"]["additionalContext"] == "REMINDER::EN" + + def test_main_is_silent_on_invalid_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/usage_session_resume.py b/usage_session_resume.py index c3e4002..ee29ffc 100644 --- a/usage_session_resume.py +++ b/usage_session_resume.py @@ -59,6 +59,13 @@ def _configure_windows_utf8_output() -> None: cast(Any, stream).reconfigure(encoding="utf-8") +def _read_stdin_utf8() -> str: + buffer = getattr(sys.stdin, "buffer", None) + if buffer is None: + return sys.stdin.read() + return cast(bytes, buffer.read()).decode("utf-8", "replace") + + 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")) @@ -253,7 +260,7 @@ def _configure_windows_utf8_output() -> None: def main() -> int: _configure_windows_utf8_output() try: - payload = json.loads(sys.stdin.read() or "{}") + payload = json.loads(_read_stdin_utf8() or "{}") except (json.JSONDecodeError, ValueError): return 0 if not isinstance(payload, dict): diff --git a/usage_statusline.py b/usage_statusline.py index f4c6d51..8374e96 100644 --- a/usage_statusline.py +++ b/usage_statusline.py @@ -41,6 +41,13 @@ def _configure_windows_utf8_output() -> None: cast(Any, stream).reconfigure(encoding="utf-8") +def _read_stdin_utf8() -> str: + buffer = getattr(sys.stdin, "buffer", None) + if buffer is None: + return sys.stdin.read() + return cast(bytes, buffer.read()).decode("utf-8", "replace") + + _fcntl: Any = None _msvcrt: Any = None if sys.platform == "win32": @@ -684,7 +691,7 @@ def render(data: Dict[str, Any], now: datetime) -> str: def main() -> None: _configure_windows_utf8_output() try: - raw = sys.stdin.read() + raw = _read_stdin_utf8() except Exception as exc: _debug("stdin read failed", exc) return diff --git a/usage_statusline_forwarder.py b/usage_statusline_forwarder.py index b80a5d1..60b871a 100644 --- a/usage_statusline_forwarder.py +++ b/usage_statusline_forwarder.py @@ -34,12 +34,21 @@ def _configure_windows_utf8_output() -> None: cast(Any, stream).reconfigure(encoding="utf-8") +def _read_stdin_utf8() -> str: + buffer = getattr(sys.stdin, "buffer", None) + if buffer is None: + return sys.stdin.read() + return cast(bytes, buffer.read()).decode("utf-8", "replace") + + def _run_hook(py: str, hook: str, raw: str) -> str: try: result = subprocess.run( [py, hook], input=raw, text=True, + encoding="utf-8", + errors="replace", check=False, capture_output=True, timeout=TIMEOUT_SECONDS, @@ -51,7 +60,7 @@ def _run_hook(py: str, hook: str, raw: str) -> str: def main() -> None: _configure_windows_utf8_output() - raw = sys.stdin.read() + raw = _read_stdin_utf8() if not raw.strip(): return diff --git a/usage_terse_mode.py b/usage_terse_mode.py index c966fd2..160975f 100644 --- a/usage_terse_mode.py +++ b/usage_terse_mode.py @@ -27,10 +27,18 @@ import os import sys from pathlib import Path -from typing import Any +from typing import Any, cast __version__ = "1.0" + +def _read_stdin_utf8() -> str: + buffer = getattr(sys.stdin, "buffer", None) + if buffer is None: + return sys.stdin.read() + return cast(bytes, buffer.read()).decode("utf-8", "replace") + + PROMPT_SIDECAR = Path(os.path.expanduser("~/.claude/usage-terse-prompt.json")) _DEFAULT_INSTRUCTION: dict[str, str] = { @@ -176,7 +184,7 @@ def _load_instruction(lang: str) -> str: def main() -> int: try: - payload = json.loads(sys.stdin.read() or "{}") + payload = json.loads(_read_stdin_utf8() or "{}") except (OSError, ValueError, TypeError): return 0 if not isinstance(payload, dict): diff --git a/usage_terse_reminder.py b/usage_terse_reminder.py index f94bf23..21deec4 100644 --- a/usage_terse_reminder.py +++ b/usage_terse_reminder.py @@ -28,10 +28,18 @@ import os import sys from pathlib import Path -from typing import Any +from typing import Any, cast __version__ = "1.0" + +def _read_stdin_utf8() -> str: + buffer = getattr(sys.stdin, "buffer", None) + if buffer is None: + return sys.stdin.read() + return cast(bytes, buffer.read()).decode("utf-8", "replace") + + PROMPT_SIDECAR = Path(os.path.expanduser("~/.claude/usage-terse-prompt.json")) _DEFAULT_REMINDER: dict[str, str] = { @@ -104,7 +112,7 @@ def _load_reminder(lang: str) -> str: def main() -> int: try: - payload = json.loads(sys.stdin.read() or "{}") + payload = json.loads(_read_stdin_utf8() or "{}") except (OSError, ValueError, TypeError): return 0 if not isinstance(payload, dict): From a8907805b136073cfda7c4429003a04e386f585c Mon Sep 17 00:00:00 2001 From: Loll <44865998+aqua5230@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:31:01 +0800 Subject: [PATCH 3/3] feat: Windows statusline follows system UI language and real console width Language: the four hook scripts only read USAGE_LANG/TT_LANG/LANG, which are rarely set on Windows, so output was always English. When no env var is set they now fall back to GetUserDefaultUILanguage via ctypes (same logic usage_lang.py already uses for the tray), and usage-statusline gains the missing USAGE_LANG override. Width: os.get_terminal_size(2) always fails when Claude Code spawns the hook with piped stdio (WinError 6), so get_width() stuck at the 116-column fallback and wide terminals lost the '(left)' reset-time suffixes. get_width() now probes the attached console via CONOUT$ + GetConsoleScreenBufferInfo before falling back to 116. Non-Windows behavior unchanged. Co-Authored-By: Claude Fable 5 --- tests/test_session_resume.py | 25 +++++++++ tests/test_usage_statusline.py | 83 ++++++++++++++++++++++++++++++ tests/test_usage_terse_mode.py | 26 ++++++++++ tests/test_usage_terse_reminder.py | 26 ++++++++++ usage_session_resume.py | 18 ++++++- usage_statusline.py | 82 ++++++++++++++++++++++++++++- usage_terse_mode.py | 18 ++++++- usage_terse_reminder.py | 18 ++++++- 8 files changed, 291 insertions(+), 5 deletions(-) diff --git a/tests/test_session_resume.py b/tests/test_session_resume.py index 1dd36d9..be0fa6c 100644 --- a/tests/test_session_resume.py +++ b/tests/test_session_resume.py @@ -19,6 +19,31 @@ import usage_session_resume as mod +def test_detect_lang_uses_windows_system_lang_when_env_is_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + for key in ("USAGE_LANG", "TT_LANG", "LANG"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setattr(mod, "_windows_system_lang", lambda: "zh_TW") + + assert mod._detect_lang() == "zh-TW" + + +def test_detect_lang_prefers_usage_lang_over_windows_system_lang( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("USAGE_LANG", "ja") + monkeypatch.setattr(mod, "_windows_system_lang", lambda: "zh_TW") + + assert mod._detect_lang() == "ja" + + +def test_windows_system_lang_is_empty_off_windows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(mod, "os", SimpleNamespace(name="posix")) + + assert mod._windows_system_lang() == "" + + def test_windows_output_reconfigures_both_streams(monkeypatch: pytest.MonkeyPatch) -> None: class Stream: def __init__(self) -> None: diff --git a/tests/test_usage_statusline.py b/tests/test_usage_statusline.py index b8100ac..35fec87 100644 --- a/tests/test_usage_statusline.py +++ b/tests/test_usage_statusline.py @@ -22,6 +22,89 @@ import usage_statusline +def test_get_width_uses_conout_width_after_windows_pipe_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def raise_oserror(fd: int) -> os.terminal_size: + _ = fd + raise OSError("not a terminal") + + monkeypatch.setattr( + usage_statusline, + "os", + SimpleNamespace(name="nt", get_terminal_size=raise_oserror), + ) + monkeypatch.setattr(usage_statusline, "_conout_columns", lambda: 220) + + assert usage_statusline.get_width() == 216 + + +@pytest.mark.parametrize("columns", (None, 0)) +def test_get_width_keeps_default_when_conout_is_unavailable( + monkeypatch: pytest.MonkeyPatch, + columns: int | None, +) -> None: + def raise_oserror(fd: int) -> os.terminal_size: + _ = fd + raise OSError("not a terminal") + + monkeypatch.setattr( + usage_statusline, + "os", + SimpleNamespace(name="nt", get_terminal_size=raise_oserror), + ) + monkeypatch.setattr(usage_statusline, "_conout_columns", lambda: columns) + + assert usage_statusline.get_width() == 116 + + +def test_get_width_does_not_probe_conout_off_windows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def raise_oserror(fd: int) -> os.terminal_size: + _ = fd + raise OSError("not a terminal") + + def fail_probe() -> int: + raise AssertionError("CONOUT$ probe should not run off Windows") + + monkeypatch.setattr( + usage_statusline, + "os", + SimpleNamespace(name="posix", get_terminal_size=raise_oserror), + ) + monkeypatch.setattr(usage_statusline, "_conout_columns", fail_probe) + + assert usage_statusline.get_width() == 116 + + +def test_statusline_detect_lang_uses_windows_system_lang_when_env_is_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + for key in ("USAGE_LANG", "TT_LANG", "LANG"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setattr(usage_statusline, "_windows_system_lang", lambda: "zh_TW") + + assert usage_statusline._statusline_detect_lang({}) == "en" + assert usage_statusline._statusline_detect_lang() == "zh-TW" + + +def test_statusline_detect_lang_prefers_usage_lang_over_windows_system_lang( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(usage_statusline, "_windows_system_lang", lambda: "zh_TW") + + assert usage_statusline._statusline_detect_lang({"USAGE_LANG": "ja"}) == "ja" + + +def test_statusline_windows_system_lang_is_empty_off_windows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(usage_statusline, "os", SimpleNamespace(name="posix")) + + assert usage_statusline._windows_system_lang() == "" + + def test_windows_output_reconfigures_both_streams(monkeypatch: pytest.MonkeyPatch) -> None: class Stream: def __init__(self) -> None: diff --git a/tests/test_usage_terse_mode.py b/tests/test_usage_terse_mode.py index d988c46..e4f06f4 100644 --- a/tests/test_usage_terse_mode.py +++ b/tests/test_usage_terse_mode.py @@ -9,12 +9,38 @@ import io import json from pathlib import Path +from types import SimpleNamespace import pytest import usage_terse_mode as mod +def test_detect_lang_uses_windows_system_lang_when_env_is_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + for key in ("USAGE_LANG", "TT_LANG", "LANG"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setattr(mod, "_windows_system_lang", lambda: "zh_TW") + + assert mod._detect_lang() == "zh-TW" + + +def test_detect_lang_prefers_usage_lang_over_windows_system_lang( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("USAGE_LANG", "ja") + monkeypatch.setattr(mod, "_windows_system_lang", lambda: "zh_TW") + + assert mod._detect_lang() == "ja" + + +def test_windows_system_lang_is_empty_off_windows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(mod, "os", SimpleNamespace(name="posix")) + + assert mod._windows_system_lang() == "" + + def _sidecar(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: sidecar = tmp_path / "usage-terse-prompt.json" sidecar.write_text( diff --git a/tests/test_usage_terse_reminder.py b/tests/test_usage_terse_reminder.py index e150059..e947291 100644 --- a/tests/test_usage_terse_reminder.py +++ b/tests/test_usage_terse_reminder.py @@ -9,12 +9,38 @@ import io import json from pathlib import Path +from types import SimpleNamespace import pytest import usage_terse_reminder as mod +def test_detect_lang_uses_windows_system_lang_when_env_is_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + for key in ("USAGE_LANG", "TT_LANG", "LANG"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setattr(mod, "_windows_system_lang", lambda: "zh_TW") + + assert mod._detect_lang() == "zh-TW" + + +def test_detect_lang_prefers_usage_lang_over_windows_system_lang( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("USAGE_LANG", "ja") + monkeypatch.setattr(mod, "_windows_system_lang", lambda: "zh_TW") + + assert mod._detect_lang() == "ja" + + +def test_windows_system_lang_is_empty_off_windows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(mod, "os", SimpleNamespace(name="posix")) + + assert mod._windows_system_lang() == "" + + def _sidecar(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: sidecar = tmp_path / "usage-terse-prompt.json" sidecar.write_text( diff --git a/usage_session_resume.py b/usage_session_resume.py index ee29ffc..8c67503 100644 --- a/usage_session_resume.py +++ b/usage_session_resume.py @@ -782,12 +782,28 @@ def _project_from_cwd(cwd: str) -> str: return parts[-1] if parts and parts[-1] else (rel or "unknown") +def _windows_system_lang() -> str: + if os.name != "nt": + return "" + try: + import ctypes + import locale as _locale + + windll = getattr(ctypes, "windll", None) + if windll is None: + return "" + lang_id = int(windll.kernel32.GetUserDefaultUILanguage()) + return _locale.windows_locale.get(lang_id, "") or "" + except Exception: + return "" + + def _detect_lang() -> str: for key in ("USAGE_LANG", "TT_LANG", "LANG"): value = os.environ.get(key, "").strip() if value: return _normalize_lang(value) - return "en" + return _normalize_lang(_windows_system_lang()) def _normalize_lang(code: str) -> str: diff --git a/usage_statusline.py b/usage_statusline.py index 8374e96..342e9f1 100644 --- a/usage_statusline.py +++ b/usage_statusline.py @@ -214,10 +214,32 @@ def _read_stdin_utf8() -> str: } +def _windows_system_lang() -> str: + if os.name != "nt": + return "" + try: + import ctypes + import locale as _locale + + windll = getattr(ctypes, "windll", None) + if windll is None: + return "" + lang_id = int(windll.kernel32.GetUserDefaultUILanguage()) + return _locale.windows_locale.get(lang_id, "") or "" + except Exception: + return "" + + def _statusline_detect_lang(env: Optional[Dict[str, str]] = None) -> str: source = os.environ if env is None else env - override = source.get("TT_LANG", "").strip() - raw = override or source.get("LANG", "") + raw = "" + for key in ("USAGE_LANG", "TT_LANG", "LANG"): + value = source.get(key, "").strip() + if value: + raw = value + break + if not raw and env is None: + raw = _windows_system_lang() code = raw.split(".")[0].replace("_", "-") table = { "zh-TW": "zh-TW", @@ -389,10 +411,66 @@ def vlen(s: str) -> int: return visible +def _conout_columns() -> Optional[int]: + """Return the active Windows console window width, if one is available.""" + if os.name != "nt": + return None + try: + import ctypes + from ctypes import wintypes + + class _CSBI(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes._COORD), + ("dwCursorPosition", wintypes._COORD), + ("wAttributes", wintypes.WORD), + ("srWindow", wintypes.SMALL_RECT), + ("dwMaximumWindowSize", wintypes._COORD), + ] + + win_dll = getattr(ctypes, "WinDLL", None) + if win_dll is None: + return None + kernel32 = win_dll("kernel32", use_last_error=True) + create_file = kernel32.CreateFileW + create_file.restype = wintypes.HANDLE + get_console_info = kernel32.GetConsoleScreenBufferInfo + get_console_info.argtypes = [wintypes.HANDLE, ctypes.POINTER(_CSBI)] + close_handle = kernel32.CloseHandle + close_handle.argtypes = [wintypes.HANDLE] + + handle = create_file( + "CONOUT$", + 0x80000000 | 0x40000000, + 0x00000001 | 0x00000002, + None, + 3, + 0, + None, + ) + invalid_handle_value = ctypes.c_void_p(-1).value + if handle in (None, 0, -1, invalid_handle_value): + return None + try: + csbi = _CSBI() + if not get_console_info(handle, ctypes.byref(csbi)): + return None + columns = csbi.srWindow.Right - csbi.srWindow.Left + 1 + return columns if columns > 0 else None + finally: + close_handle(handle) + except Exception: + return None + + def get_width() -> int: try: return max(1, os.get_terminal_size(2).columns - 4) except Exception: + if os.name == "nt": + columns = _conout_columns() + if columns: + return max(1, columns - 4) return 116 diff --git a/usage_terse_mode.py b/usage_terse_mode.py index 160975f..8b231a8 100644 --- a/usage_terse_mode.py +++ b/usage_terse_mode.py @@ -140,12 +140,28 @@ def _read_stdin_utf8() -> str: } +def _windows_system_lang() -> str: + if os.name != "nt": + return "" + try: + import ctypes + import locale as _locale + + windll = getattr(ctypes, "windll", None) + if windll is None: + return "" + lang_id = int(windll.kernel32.GetUserDefaultUILanguage()) + return _locale.windows_locale.get(lang_id, "") or "" + except Exception: + return "" + + def _detect_lang() -> str: for key in ("USAGE_LANG", "TT_LANG", "LANG"): value = os.environ.get(key, "").strip() if value: return _normalize_lang(value) - return "en" + return _normalize_lang(_windows_system_lang()) def _normalize_lang(code: str) -> str: diff --git a/usage_terse_reminder.py b/usage_terse_reminder.py index 21deec4..7880fc6 100644 --- a/usage_terse_reminder.py +++ b/usage_terse_reminder.py @@ -68,12 +68,28 @@ def _read_stdin_utf8() -> str: } +def _windows_system_lang() -> str: + if os.name != "nt": + return "" + try: + import ctypes + import locale as _locale + + windll = getattr(ctypes, "windll", None) + if windll is None: + return "" + lang_id = int(windll.kernel32.GetUserDefaultUILanguage()) + return _locale.windows_locale.get(lang_id, "") or "" + except Exception: + return "" + + def _detect_lang() -> str: for key in ("USAGE_LANG", "TT_LANG", "LANG"): value = os.environ.get(key, "").strip() if value: return _normalize_lang(value) - return "en" + return _normalize_lang(_windows_system_lang()) def _normalize_lang(code: str) -> str: