usage-cli 0.29.32__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. adapters/__init__.py +5 -0
  2. adapters/agy.py +68 -0
  3. adapters/claude.py +215 -0
  4. adapters/codex.py +209 -0
  5. adapters/rate_limits.py +76 -0
  6. adapters/registry.py +17 -0
  7. adapters/types.py +139 -0
  8. agy_disk_cache.py +135 -0
  9. agy_loader.py +416 -0
  10. agy_quota_probe.py +748 -0
  11. agy_window_keeper.py +185 -0
  12. analyzer/__init__.py +5 -0
  13. analyzer/aggregator.py +139 -0
  14. analyzer/blocks.py +80 -0
  15. analyzer/diagnoser.py +638 -0
  16. analyzer/insights.py +277 -0
  17. analyzer/persona_loader.py +199 -0
  18. analyzer/reporter.py +989 -0
  19. analyzer/subscription.py +108 -0
  20. burn_rate.py +75 -0
  21. cache_quarantine.py +50 -0
  22. codex_disk_cache.py +227 -0
  23. codex_events.py +136 -0
  24. codex_fork_replay.py +111 -0
  25. codex_loader.py +1426 -0
  26. codex_paths.py +20 -0
  27. critter_frames.py +26 -0
  28. discussion_bridge.py +1196 -0
  29. discussion_cli.py +844 -0
  30. discussion_session.py +622 -0
  31. discussion_usage.py +13 -0
  32. discussion_window.py +955 -0
  33. disk_cache_common.py +132 -0
  34. disk_cache_lifecycle.py +39 -0
  35. doctor.py +452 -0
  36. fsevents_watch.py +207 -0
  37. history_disk_cache.py +110 -0
  38. history_loader.py +416 -0
  39. i18n.py +88 -0
  40. jsonl_limits.py +17 -0
  41. jsonl_utils.py +40 -0
  42. login_item.py +154 -0
  43. main.py +387 -0
  44. menubar.py +1201 -0
  45. menubar_actions.py +204 -0
  46. menubar_agy.py +193 -0
  47. menubar_chrome.py +156 -0
  48. menubar_menu.py +169 -0
  49. menubar_notify.py +102 -0
  50. menubar_popover.py +233 -0
  51. menubar_prefs.py +118 -0
  52. menubar_refresh.py +285 -0
  53. menubar_state.py +1200 -0
  54. menubar_title.py +157 -0
  55. menubar_update.py +123 -0
  56. panel_window.py +78 -0
  57. panel_window_state.py +159 -0
  58. panels/__init__.py +186 -0
  59. panels/base.py +83 -0
  60. panels/dynamic_height.py +140 -0
  61. panels/payload.py +178 -0
  62. panels/web_panel.py +513 -0
  63. panels/window_drag.py +56 -0
  64. prefs.py +44 -0
  65. pricing.py +452 -0
  66. project_resolver.py +112 -0
  67. service_status.py +383 -0
  68. session_hooks.py +1154 -0
  69. setup_app.py +171 -0
  70. setup_hook.py +1011 -0
  71. statusline_settings.py +160 -0
  72. talent_market_bridge.py +243 -0
  73. time_utils.py +24 -0
  74. tui.py +288 -0
  75. tui_sprite.py +206 -0
  76. ui/__init__.py +5 -0
  77. ui/html_report.py +923 -0
  78. ui/report_scripts.py +251 -0
  79. ui/report_styles.py +370 -0
  80. ui/tables.py +888 -0
  81. update_checker.py +156 -0
  82. update_gate.py +66 -0
  83. update_release_notes.py +49 -0
  84. usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
  85. usage_cli-0.29.32.dist-info/METADATA +223 -0
  86. usage_cli-0.29.32.dist-info/RECORD +109 -0
  87. usage_cli-0.29.32.dist-info/WHEEL +5 -0
  88. usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
  89. usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
  90. usage_cli-0.29.32.dist-info/top_level.txt +80 -0
  91. usage_cli.py +827 -0
  92. usage_client.py +487 -0
  93. usage_diagnosis_snapshot.py +143 -0
  94. usage_dir_sweeper.py +100 -0
  95. usage_lang.py +79 -0
  96. usage_logging.py +75 -0
  97. usage_notifications.py +96 -0
  98. usage_rate.py +97 -0
  99. usage_session_resume.py +913 -0
  100. usage_statusline.py +810 -0
  101. usage_statusline_agy.py +397 -0
  102. usage_statusline_forwarder.py +88 -0
  103. usage_terse_mode.py +223 -0
  104. usage_terse_reminder.py +151 -0
  105. win_login_item.py +53 -0
  106. window_keeper.py +264 -0
  107. windows_watch.py +443 -0
  108. wintray.py +2014 -0
  109. wintray_menu.py +136 -0
usage_statusline.py ADDED
@@ -0,0 +1,810 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: AGPL-3.0-only
3
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
4
+ #
5
+ # Part of "usage". Free software licensed under the GNU Affero General Public
6
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
7
+
8
+ # ruff: noqa: SIM105, UP006, UP035, UP045
9
+ """Claude Code statusLine hook:把 Claude Code 推來的狀態 JSON 持久化並渲染狀態列。
10
+
11
+ Claude Code 每次刷新 statusLine 時會把當前 session 的完整 JSON
12
+ (含 rate_limits.five_hour / seven_day、context_window、cost 等)
13
+ 從 stdin 傳給這個 script。我們會落地到 usage-status.json,
14
+ 再輸出多行彩色 statusLine 文字供 Claude Code 顯示。
15
+
16
+ usage 主程式會反向讀這個檔,呈現給 menubar / TUI。
17
+
18
+ 刻意只用標準庫,方便用系統 python3 跑。
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import errno
24
+ import json
25
+ import os
26
+ import sys
27
+ import tempfile
28
+ import time
29
+ from contextlib import contextmanager, suppress
30
+ from datetime import datetime, timezone
31
+ from typing import Any, Dict, List, Optional, Tuple, cast
32
+
33
+
34
+ def _configure_windows_utf8_output() -> None:
35
+ """Make hook output UTF-8 when Claude Code reads a Windows pipe."""
36
+ if os.name != "nt":
37
+ return
38
+ for stream in (sys.stdout, sys.stderr):
39
+ with suppress(AttributeError, OSError, ValueError):
40
+ # Test runners and embedders may replace the TextIOWrapper streams.
41
+ cast(Any, stream).reconfigure(encoding="utf-8")
42
+
43
+
44
+ def _read_stdin_utf8() -> str:
45
+ buffer = getattr(sys.stdin, "buffer", None)
46
+ if buffer is None:
47
+ return sys.stdin.read()
48
+ return cast(bytes, buffer.read()).decode("utf-8", "replace")
49
+
50
+
51
+ _fcntl: Any = None
52
+ _msvcrt: Any = None
53
+ if sys.platform == "win32":
54
+ try:
55
+ import msvcrt
56
+ except ImportError:
57
+ pass
58
+ else:
59
+ _msvcrt = msvcrt
60
+ else:
61
+ try:
62
+ import fcntl
63
+ except ImportError:
64
+ pass
65
+ else:
66
+ _fcntl = fcntl
67
+ fcntl = _fcntl
68
+ msvcrt = _msvcrt
69
+
70
+ __version__ = "1.1"
71
+
72
+ STATUS_FILE = os.path.expanduser("~/.claude/usage-status.json")
73
+ LOCK_FILE = os.path.expanduser("~/.claude/usage-status.lock")
74
+ # Windows lock acquisition: poll rather than fail instantly on contention.
75
+ _LOCK_TIMEOUT_S = 10.0
76
+ _LOCK_POLL_INTERVAL_S = 0.001
77
+ # msvcrt.locking reports a contended byte as EACCES; EDEADLOCK is what it
78
+ # raises once its own internal retries are exhausted.
79
+ _LOCK_CONTENDED_ERRNOS = frozenset(
80
+ (errno.EACCES, getattr(errno, "EDEADLOCK", errno.EDEADLK), errno.EDEADLK)
81
+ )
82
+ PREFERENCES_FILE = os.path.expanduser("~/.claude/usage-preferences.json")
83
+ CONTEXT_BURN_FILE = os.path.expanduser("~/.claude/usage-context-burn.json")
84
+ UPDATE_HINT_STALE_SECONDS = 30 * 86400
85
+ # Context fill at which a /clear or /compact nudge is worth the noise. Set below
86
+ # the default auto-compact line (~80%) so the user can act before the lossy
87
+ # automatic pass decides for them. Long contexts degrade quality well before they
88
+ # fill: models lose the middle of long inputs and effective context is often only
89
+ # ~50-65% of the window. Refs: Liu et al. "Lost in the Middle" (2023,
90
+ # arXiv:2307.03172), NVIDIA RULER (2024, arXiv:2404.06654), BABILong (2024,
91
+ # arXiv:2406.10149).
92
+ HEAVY_CONTEXT_PERCENT = 70.0
93
+ CONTEXT_BURN_RESET_DROP_PERCENT = 5.0
94
+ CONTEXT_BURN_STALE_SECONDS = 4 * 60 * 60
95
+ CONTEXT_BURN_FAST_PERCENT_PER_MIN = 2.0
96
+ CONTEXT_BURN_VERY_FAST_PERCENT_PER_MIN = 4.0
97
+ CONTEXT_BURN_FAST_THRESHOLD_PERCENT = 60.0
98
+ CONTEXT_BURN_VERY_FAST_THRESHOLD_PERCENT = 55.0
99
+ CONTEXT_BURN_THRESHOLD_FLOOR_PERCENT = 55.0
100
+ # 2%/min reaches the old warning line from 50% in about 10 minutes; 4%/min is
101
+ # the "large paste or long replay" case that should warn at the floor.
102
+ STATUSLINE_TRANSLATIONS = {
103
+ "zh-TW": {
104
+ "five_hour": "5小時",
105
+ "seven_day": "7天",
106
+ "context": "對話窗",
107
+ "total": "累計",
108
+ "in_short": "問:",
109
+ "out_short": "答:",
110
+ "this_turn": "本輪",
111
+ "cached": "快取:",
112
+ "cost": "花費:",
113
+ "session_dur": "會話時長:",
114
+ "remaining_prefix": "剩",
115
+ "effort_xhigh": "深思熟慮",
116
+ "effort_high": "深思",
117
+ "effort_normal": "標準",
118
+ "effort_low": "速答",
119
+ "fast_mode": "⚡快速",
120
+ "update_available_suffix": "可更新",
121
+ "warn_clear": "越長 AI 越容易漏記中段 · 切任務 /clear,續做 /compact 留重點",
122
+ },
123
+ "zh-CN": {
124
+ "five_hour": "5小时",
125
+ "seven_day": "7天",
126
+ "context": "对话窗",
127
+ "total": "累计",
128
+ "in_short": "问:",
129
+ "out_short": "答:",
130
+ "this_turn": "本轮",
131
+ "cached": "缓存:",
132
+ "cost": "花费:",
133
+ "session_dur": "会话时长:",
134
+ "remaining_prefix": "剩",
135
+ "effort_xhigh": "深思熟虑",
136
+ "effort_high": "深思",
137
+ "effort_normal": "标准",
138
+ "effort_low": "速答",
139
+ "fast_mode": "⚡快速",
140
+ "update_available_suffix": "可更新",
141
+ "warn_clear": "越长 AI 越容易漏记中段 · 切任务 /clear,续做 /compact 留重点",
142
+ },
143
+ "en": {
144
+ "five_hour": "5h",
145
+ "seven_day": "7d",
146
+ "context": "Context",
147
+ "total": "Total",
148
+ "in_short": "in:",
149
+ "out_short": "out:",
150
+ "this_turn": "this turn",
151
+ "cached": "Cached:",
152
+ "cost": "Cost:",
153
+ "session_dur": "Session:",
154
+ "remaining_prefix": "left",
155
+ "effort_xhigh": "Extended",
156
+ "effort_high": "Deep",
157
+ "effort_normal": "Standard",
158
+ "effort_low": "Quick",
159
+ "fast_mode": "⚡Fast",
160
+ "update_available_suffix": "available",
161
+ "warn_clear": "longer chats lose the middle · /clear to switch, /compact to keep focus",
162
+ },
163
+ "ja": {
164
+ "five_hour": "5時間",
165
+ "seven_day": "7日",
166
+ "context": "コンテキスト",
167
+ "total": "累計",
168
+ "in_short": "入:",
169
+ "out_short": "出:",
170
+ "this_turn": "今回",
171
+ "cached": "キャッシュ:",
172
+ "cost": "費用:",
173
+ "session_dur": "セッション時間:",
174
+ "remaining_prefix": "残り",
175
+ "effort_xhigh": "熟考",
176
+ "effort_high": "熟考",
177
+ "effort_normal": "標準",
178
+ "effort_low": "即答",
179
+ "fast_mode": "⚡高速",
180
+ "update_available_suffix": "更新あり",
181
+ "warn_clear": "長いほど中盤を忘れがち · 切替は /clear、継続は /compact で要点保持",
182
+ },
183
+ "ko": {
184
+ "five_hour": "5시간",
185
+ "seven_day": "7일",
186
+ "context": "컨텍스트",
187
+ "total": "누적",
188
+ "in_short": "입:",
189
+ "out_short": "출:",
190
+ "this_turn": "이번 턴",
191
+ "cached": "캐시:",
192
+ "cost": "비용:",
193
+ "session_dur": "세션 시간:",
194
+ "remaining_prefix": "남음",
195
+ "effort_xhigh": "심사숙고",
196
+ "effort_high": "깊은 사고",
197
+ "effort_normal": "표준",
198
+ "effort_low": "빠른 답변",
199
+ "fast_mode": "⚡빠름",
200
+ "update_available_suffix": "업데이트",
201
+ "warn_clear": "길수록 중간 내용을 놓침 · 전환은 /clear, 계속은 /compact로 핵심 유지",
202
+ },
203
+ }
204
+ C = {
205
+ "green": "\033[38;5;80m",
206
+ "yellow": "\033[33m",
207
+ "red": "\033[31m",
208
+ "cyan": "\033[36m",
209
+ "blue": "\033[38;5;39m",
210
+ "magenta": "\033[38;5;111m",
211
+ "peach": "\033[38;5;216m",
212
+ "dim": "\033[2m",
213
+ "reset": "\033[0m",
214
+ }
215
+
216
+
217
+ def _windows_system_lang() -> str:
218
+ if os.name != "nt":
219
+ return ""
220
+ try:
221
+ import ctypes
222
+ import locale as _locale
223
+
224
+ windll = getattr(ctypes, "windll", None)
225
+ if windll is None:
226
+ return ""
227
+ lang_id = int(windll.kernel32.GetUserDefaultUILanguage())
228
+ return _locale.windows_locale.get(lang_id, "") or ""
229
+ except Exception:
230
+ return ""
231
+
232
+
233
+ def _statusline_detect_lang(env: Optional[Dict[str, str]] = None) -> str:
234
+ source = os.environ if env is None else env
235
+ raw = ""
236
+ # Windows 上的 LANG 多半是 Git Bash / MSYS 帶進來的,不代表使用者的系統語言。
237
+ keys = (
238
+ ("USAGE_LANG", "TT_LANG") if sys.platform == "win32" else ("USAGE_LANG", "TT_LANG", "LANG")
239
+ )
240
+ for key in keys:
241
+ value = source.get(key, "").strip()
242
+ if value:
243
+ raw = value
244
+ break
245
+ if not raw and env is None:
246
+ raw = _windows_system_lang()
247
+ code = raw.split(".")[0].replace("_", "-")
248
+ table = {
249
+ "zh-TW": "zh-TW",
250
+ "zh-HK": "zh-TW",
251
+ "zh-CN": "zh-CN",
252
+ "zh": "zh-CN",
253
+ "ja-JP": "ja",
254
+ "ja": "ja",
255
+ "ko-KR": "ko",
256
+ "ko": "ko",
257
+ }
258
+ return table.get(code, "en")
259
+
260
+
261
+ def _detect_lang() -> str:
262
+ return _statusline_detect_lang()
263
+
264
+
265
+ def _t(key: str) -> str:
266
+ lang = _detect_lang()
267
+ table = STATUSLINE_TRANSLATIONS.get(lang, STATUSLINE_TRANSLATIONS["en"])
268
+ return table.get(key, key)
269
+
270
+
271
+ def _read_update_hint(now_ts: float) -> Optional[str]:
272
+ """Return latest_version when an update is fresh, available, and not skipped."""
273
+ try:
274
+ with open(PREFERENCES_FILE, encoding="utf-8") as f:
275
+ prefs = json.load(f)
276
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
277
+ return None
278
+ if not isinstance(prefs, dict):
279
+ return None
280
+ info = prefs.get("last_update_check")
281
+ if not isinstance(info, dict):
282
+ return None
283
+ latest = info.get("latest_version")
284
+ current = info.get("current_version")
285
+ checked_at = info.get("checked_at")
286
+ if not isinstance(latest, str) or not isinstance(current, str):
287
+ return None
288
+ if not isinstance(checked_at, (int, float)) or isinstance(checked_at, bool):
289
+ return None
290
+ if latest == current:
291
+ return None
292
+ if prefs.get("update_skipped_version") == latest:
293
+ return None
294
+ if now_ts - float(checked_at) > UPDATE_HINT_STALE_SECONDS:
295
+ return None
296
+ return latest
297
+
298
+
299
+ def _rate_limits_complete(rate_limits: Any) -> bool:
300
+ if not isinstance(rate_limits, dict):
301
+ return False
302
+ five = rate_limits.get("five_hour")
303
+ seven = rate_limits.get("seven_day")
304
+ if not isinstance(five, dict) or not isinstance(seven, dict):
305
+ return False
306
+ return five.get("used_percentage") is not None and seven.get("used_percentage") is not None
307
+
308
+
309
+ def _acquire_msvcrt_lock(lock_fd: int) -> bool:
310
+ """Block (bounded) until the byte is locked; False if locking is unavailable.
311
+
312
+ msvcrt has no blocking-with-timeout mode: LK_LOCK retries on a fixed
313
+ one-second granularity, and LK_NBLCK gives up instantly. Poll LK_NBLCK so a
314
+ contended lock is waited out rather than silently skipped — dropping the
315
+ lock would run save()'s read-modify-write unsynchronized, which fcntl.flock
316
+ never does on POSIX.
317
+ """
318
+ deadline = time.monotonic() + _LOCK_TIMEOUT_S
319
+ while True:
320
+ try:
321
+ if os.fstat(lock_fd).st_size == 0:
322
+ os.write(lock_fd, b"\0")
323
+ os.lseek(lock_fd, 0, os.SEEK_SET)
324
+ msvcrt.locking(lock_fd, msvcrt.LK_NBLCK, 1)
325
+ return True
326
+ except OSError as exc:
327
+ if exc.errno not in _LOCK_CONTENDED_ERRNOS:
328
+ # Locking is unsupported on this descriptor or filesystem;
329
+ # spinning would not help. Fall back to the atomic write alone.
330
+ return False
331
+ if time.monotonic() >= deadline:
332
+ return False
333
+ time.sleep(_LOCK_POLL_INTERVAL_S)
334
+
335
+
336
+ @contextmanager
337
+ def _exclusive_lock(lock_fd: int) -> Any:
338
+ """Use the native lock when available; preserve atomic writes without one."""
339
+ if fcntl is not None:
340
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
341
+ try:
342
+ yield
343
+ finally:
344
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
345
+ return
346
+
347
+ locked = _acquire_msvcrt_lock(lock_fd) if msvcrt is not None else False
348
+ try:
349
+ yield
350
+ finally:
351
+ if locked and msvcrt is not None:
352
+ try:
353
+ os.lseek(lock_fd, 0, os.SEEK_SET)
354
+ msvcrt.locking(lock_fd, msvcrt.LK_UNLCK, 1)
355
+ except OSError:
356
+ pass
357
+
358
+
359
+ def save(data: Dict[str, Any], now: datetime) -> None:
360
+ data["_received_at"] = now.isoformat()
361
+ data["_received_at_ts"] = now.timestamp()
362
+ target_dir = os.path.dirname(STATUS_FILE)
363
+ lock_file = os.path.join(target_dir, os.path.basename(LOCK_FILE))
364
+ os.makedirs(target_dir, exist_ok=True)
365
+ os.makedirs(os.path.dirname(lock_file), exist_ok=True)
366
+ tmp_path: Optional[str] = None
367
+ lock_fd = os.open(lock_file, os.O_CREAT | os.O_RDWR, 0o600)
368
+ try:
369
+ with _exclusive_lock(lock_fd):
370
+ if not _rate_limits_complete(data.get("rate_limits")):
371
+ try:
372
+ with open(STATUS_FILE, encoding="utf-8") as f:
373
+ existing = json.load(f)
374
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
375
+ existing = None
376
+ if isinstance(existing, dict):
377
+ existing_rate_limits = existing.get("rate_limits")
378
+ if _rate_limits_complete(existing_rate_limits):
379
+ data["rate_limits"] = existing_rate_limits
380
+ fd, tmp_path = tempfile.mkstemp(dir=target_dir, suffix=".tmp")
381
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
382
+ json.dump(data, f, ensure_ascii=False)
383
+ os.replace(tmp_path, STATUS_FILE)
384
+ tmp_path = None
385
+ finally:
386
+ os.close(lock_fd)
387
+ if tmp_path and os.path.exists(tmp_path):
388
+ try:
389
+ os.unlink(tmp_path)
390
+ except OSError:
391
+ pass
392
+
393
+
394
+ def _debug(message: str, exc: Optional[Exception] = None) -> None:
395
+ if os.environ.get("USAGE_DEBUG") != "1":
396
+ return
397
+ if exc is None:
398
+ print(f"usage_statusline: {message}", file=sys.stderr)
399
+ return
400
+ print(f"usage_statusline: {message}: {exc}", file=sys.stderr)
401
+
402
+
403
+ def vlen(s: str) -> int:
404
+ visible = 0
405
+ i = 0
406
+ while i < len(s):
407
+ if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
408
+ i += 2
409
+ while i < len(s) and s[i] != "m":
410
+ i += 1
411
+ i += 1
412
+ continue
413
+ visible += 1
414
+ i += 1
415
+ return visible
416
+
417
+
418
+ def _conout_columns() -> Optional[int]:
419
+ """Return the active Windows console window width, if one is available."""
420
+ if os.name != "nt":
421
+ return None
422
+ try:
423
+ import ctypes
424
+ from ctypes import wintypes
425
+
426
+ class _CSBI(ctypes.Structure):
427
+ _fields_ = [
428
+ ("dwSize", wintypes._COORD),
429
+ ("dwCursorPosition", wintypes._COORD),
430
+ ("wAttributes", wintypes.WORD),
431
+ ("srWindow", wintypes.SMALL_RECT),
432
+ ("dwMaximumWindowSize", wintypes._COORD),
433
+ ]
434
+
435
+ win_dll = getattr(ctypes, "WinDLL", None)
436
+ if win_dll is None:
437
+ return None
438
+ kernel32 = win_dll("kernel32", use_last_error=True)
439
+ create_file = kernel32.CreateFileW
440
+ create_file.restype = wintypes.HANDLE
441
+ get_console_info = kernel32.GetConsoleScreenBufferInfo
442
+ get_console_info.argtypes = [wintypes.HANDLE, ctypes.POINTER(_CSBI)]
443
+ close_handle = kernel32.CloseHandle
444
+ close_handle.argtypes = [wintypes.HANDLE]
445
+
446
+ handle = create_file(
447
+ "CONOUT$",
448
+ 0x80000000 | 0x40000000,
449
+ 0x00000001 | 0x00000002,
450
+ None,
451
+ 3,
452
+ 0,
453
+ None,
454
+ )
455
+ invalid_handle_value = ctypes.c_void_p(-1).value
456
+ if handle in (None, 0, -1, invalid_handle_value):
457
+ return None
458
+ try:
459
+ csbi = _CSBI()
460
+ if not get_console_info(handle, ctypes.byref(csbi)):
461
+ return None
462
+ columns = csbi.srWindow.Right - csbi.srWindow.Left + 1
463
+ return columns if columns > 0 else None
464
+ finally:
465
+ close_handle(handle)
466
+ except Exception:
467
+ return None
468
+
469
+
470
+ def get_width() -> int:
471
+ try:
472
+ return max(1, os.get_terminal_size(2).columns - 4)
473
+ except Exception:
474
+ if os.name == "nt":
475
+ columns = _conout_columns()
476
+ if columns:
477
+ return max(1, columns - 4)
478
+ return 116
479
+
480
+
481
+ def color_by_pct(pct: float) -> str:
482
+ if pct < 50:
483
+ return "\033[38;5;42m"
484
+ if pct < 80:
485
+ return "\033[38;5;214m"
486
+ return "\033[38;5;160m"
487
+
488
+
489
+ def fmt_tokens(n: Any) -> str:
490
+ try:
491
+ value = int(n)
492
+ except (TypeError, ValueError):
493
+ value = 0
494
+ if value >= 1_000_000:
495
+ return f"{value / 1_000_000:.1f}M"
496
+ if value >= 1_000:
497
+ return f"{value / 1_000:.0f}k"
498
+ return str(value)
499
+
500
+
501
+ def progress_bar(value: Any, bar_width: int = 8) -> str:
502
+ filled_char = "■"
503
+ empty_char = "□"
504
+ if value is None:
505
+ return empty_char * bar_width + " n/a"
506
+ pct = max(0.0, min(100.0, float(value)))
507
+ filled = round(pct / 100 * bar_width)
508
+ return (
509
+ f"{color_by_pct(pct)}{filled_char * filled}{C['reset']}"
510
+ f"{empty_char * (bar_width - filled)} "
511
+ f"{color_by_pct(pct)}{pct:.0f}%{C['reset']}"
512
+ )
513
+
514
+
515
+ def fmt_duration(seconds: float) -> str:
516
+ if seconds >= 86400:
517
+ d = int(seconds // 86400)
518
+ rem = int(seconds % 86400)
519
+ return f"{d}d{rem // 3600}h"
520
+ if seconds >= 3600:
521
+ h = int(seconds // 3600)
522
+ m = int((seconds % 3600) // 60)
523
+ return f"{h}h{m}m"
524
+ if seconds >= 60:
525
+ return f"{int(seconds // 60)}min"
526
+ return f"{int(seconds)}s"
527
+
528
+
529
+ def safe_text(value: str) -> str:
530
+ """Drop control characters so untrusted names cannot rewrite the status line."""
531
+ return "".join(ch for ch in value if ch.isprintable())
532
+
533
+
534
+ def git_branch(cwd: str) -> str:
535
+ path = os.path.abspath(cwd)
536
+ while True:
537
+ git_path = os.path.join(path, ".git")
538
+ if os.path.isdir(git_path):
539
+ head_path = os.path.join(git_path, "HEAD")
540
+ break
541
+ if os.path.isfile(git_path):
542
+ try:
543
+ with open(git_path, encoding="utf-8") as f:
544
+ target = f.read().strip()
545
+ if target.startswith("gitdir:"):
546
+ git_dir = target.split(":", 1)[1].strip()
547
+ if not os.path.isabs(git_dir):
548
+ git_dir = os.path.normpath(os.path.join(path, git_dir))
549
+ head_path = os.path.join(git_dir, "HEAD")
550
+ break
551
+ except OSError:
552
+ return ""
553
+ parent = os.path.dirname(path)
554
+ if parent == path:
555
+ return ""
556
+ path = parent
557
+
558
+ try:
559
+ with open(head_path, encoding="utf-8") as f:
560
+ head = f.read().strip()
561
+ except OSError:
562
+ return ""
563
+ prefix = "ref: refs/heads/"
564
+ if head.startswith(prefix):
565
+ return head[len(prefix) :]
566
+ if head:
567
+ return head[:7]
568
+ return ""
569
+
570
+
571
+ def _as_dict(value: Any) -> Dict[str, Any]:
572
+ if isinstance(value, dict):
573
+ return value
574
+ return {}
575
+
576
+
577
+ def _as_float(value: Any) -> Optional[float]:
578
+ try:
579
+ return float(value)
580
+ except (TypeError, ValueError):
581
+ return None
582
+
583
+
584
+ def _read_context_burn_sample(now_ts: float) -> Optional[Tuple[float, float]]:
585
+ try:
586
+ with open(CONTEXT_BURN_FILE, encoding="utf-8") as f:
587
+ sample = json.load(f)
588
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
589
+ return None
590
+ if not isinstance(sample, dict):
591
+ return None
592
+ percent = _as_float(sample.get("percent"))
593
+ ts = _as_float(sample.get("ts"))
594
+ if percent is None or ts is None:
595
+ return None
596
+ if now_ts - ts > CONTEXT_BURN_STALE_SECONDS:
597
+ return None
598
+ return percent, ts
599
+
600
+
601
+ def _write_context_burn_sample(percent: float, now_ts: float) -> None:
602
+ target_dir = os.path.dirname(CONTEXT_BURN_FILE)
603
+ tmp_path: Optional[str] = None
604
+ try:
605
+ os.makedirs(target_dir, exist_ok=True)
606
+ fd, tmp_path = tempfile.mkstemp(dir=target_dir, suffix=".tmp")
607
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
608
+ json.dump({"percent": percent, "ts": now_ts}, f, ensure_ascii=False)
609
+ os.replace(tmp_path, CONTEXT_BURN_FILE)
610
+ tmp_path = None
611
+ except OSError:
612
+ return
613
+ finally:
614
+ if tmp_path and os.path.exists(tmp_path):
615
+ try:
616
+ os.unlink(tmp_path)
617
+ except OSError:
618
+ pass
619
+
620
+
621
+ def _context_burn_threshold(percent: float, now_ts: float) -> float:
622
+ previous = _read_context_burn_sample(now_ts)
623
+ _write_context_burn_sample(percent, now_ts)
624
+ if previous is None:
625
+ return HEAVY_CONTEXT_PERCENT
626
+
627
+ previous_percent, previous_ts = previous
628
+ if previous_percent - percent > CONTEXT_BURN_RESET_DROP_PERCENT:
629
+ return HEAVY_CONTEXT_PERCENT
630
+
631
+ elapsed_seconds = now_ts - previous_ts
632
+ if elapsed_seconds <= 0:
633
+ return HEAVY_CONTEXT_PERCENT
634
+
635
+ increase = percent - previous_percent
636
+ if increase <= 0:
637
+ return HEAVY_CONTEXT_PERCENT
638
+
639
+ percent_per_minute = increase / (elapsed_seconds / 60.0)
640
+ threshold = HEAVY_CONTEXT_PERCENT
641
+ if percent_per_minute >= CONTEXT_BURN_VERY_FAST_PERCENT_PER_MIN:
642
+ threshold = CONTEXT_BURN_VERY_FAST_THRESHOLD_PERCENT
643
+ elif percent_per_minute >= CONTEXT_BURN_FAST_PERCENT_PER_MIN:
644
+ threshold = CONTEXT_BURN_FAST_THRESHOLD_PERCENT
645
+ return max(CONTEXT_BURN_THRESHOLD_FLOOR_PERCENT, threshold)
646
+
647
+
648
+ def _heavy_warning(data: Dict[str, Any], now_ts: Optional[float] = None) -> Optional[str]:
649
+ """A /clear or /compact nudge once the context window gets heavy enough
650
+ that quality starts to slip (see HEAVY_CONTEXT_PERCENT)."""
651
+ pct = _as_float(_as_dict(data.get("context_window")).get("used_percentage"))
652
+ if pct is None:
653
+ return None
654
+ threshold = _context_burn_threshold(
655
+ pct,
656
+ datetime.now(timezone.utc).timestamp() if now_ts is None else now_ts,
657
+ )
658
+ if pct < threshold:
659
+ return None
660
+ detail = f"{_t('context')} {pct:.0f}%"
661
+ return f"\033[38;5;160m⚠ {detail} · {_t('warn_clear')}{C['reset']}"
662
+
663
+
664
+ def _render_core(data: Dict[str, Any], now: datetime) -> str:
665
+ width = get_width()
666
+ ctx = _as_dict(data.get("context_window"))
667
+ bar_w = 8 if width >= 100 else 6 if width >= 60 else 4
668
+ lang = _detect_lang()
669
+
670
+ line1: List[str] = []
671
+ project = _as_dict(data.get("workspace")).get("project_dir", "")
672
+ if isinstance(project, str) and project:
673
+ name = safe_text(os.path.basename(project))
674
+ branch = safe_text(git_branch(project))
675
+ if branch:
676
+ line1.append(f"{C['green']}{name}{C['reset']}({C['magenta']}{branch}{C['reset']})")
677
+ else:
678
+ line1.append(f"{C['green']}{name}{C['reset']}")
679
+
680
+ rl = _as_dict(data.get("rate_limits"))
681
+ rl_parts: List[Tuple[str, str, str]] = []
682
+ for key, label in (("five_hour", _t("five_hour")), ("seven_day", _t("seven_day"))):
683
+ entry = _as_dict(rl.get(key))
684
+ pct = entry.get("used_percentage")
685
+ if pct is None:
686
+ continue
687
+ pct_float = _as_float(pct)
688
+ if pct_float is None:
689
+ continue
690
+ reset_str = ""
691
+ resets_at = _as_float(entry.get("resets_at"))
692
+ if resets_at is not None:
693
+ remain = int(resets_at) - int(now.timestamp())
694
+ if remain > 0:
695
+ if lang in ("zh-TW", "zh-CN"):
696
+ reset_str = f" ({_t('remaining_prefix')}{fmt_duration(remain)})"
697
+ else:
698
+ reset_str = f" ({fmt_duration(remain)} {_t('remaining_prefix')})"
699
+ rl_parts.append(
700
+ (
701
+ f"{C['blue']}{label}:{C['reset']}{progress_bar(pct_float, bar_w)}{reset_str}",
702
+ f"{C['blue']}{label}:{C['reset']}{progress_bar(pct_float, bar_w)}",
703
+ f"{C['blue']}{label}:{C['reset']}{pct_float:.0f}%",
704
+ )
705
+ )
706
+
707
+ ctx_parts: List[str] = []
708
+ ctx_pct = _as_float(ctx.get("used_percentage"))
709
+ if ctx_pct is not None:
710
+ size = ctx.get("context_window_size", 0)
711
+ ctx_parts = [
712
+ f"{C['blue']}{_t('context')}:{C['reset']}"
713
+ f"{progress_bar(ctx_pct, bar_w)} / {fmt_tokens(size)}",
714
+ f"{C['blue']}{_t('context')}:{C['reset']}{ctx_pct:.0f}%",
715
+ ]
716
+
717
+ full = line1 + [p[0] for p in rl_parts] + (ctx_parts[:1] if ctx_parts else [])
718
+ candidate = " | ".join(full)
719
+ if vlen(candidate) <= width:
720
+ line1 = full
721
+ else:
722
+ no_reset = line1 + [p[1] for p in rl_parts] + (ctx_parts[:1] if ctx_parts else [])
723
+ candidate = " | ".join(no_reset)
724
+ if vlen(candidate) <= width:
725
+ line1 = no_reset
726
+ else:
727
+ line1 = line1 + [p[2] for p in rl_parts] + (ctx_parts[1:2] if ctx_parts else [])
728
+
729
+ cost = _as_dict(data.get("cost"))
730
+
731
+ line3: List[str] = []
732
+ duration_ms = cost.get("total_duration_ms")
733
+ duration_part = ""
734
+ if duration_ms and duration_ms > 0:
735
+ duration_part = (
736
+ f"{C['dim']}{C['magenta']}{_t('session_dur')} "
737
+ f"{fmt_duration(float(duration_ms) / 1000)}{C['reset']}"
738
+ )
739
+ line3.append(duration_part)
740
+
741
+ model_name = _as_dict(data.get("model")).get("display_name", "")
742
+ if isinstance(model_name, str) and model_name:
743
+ effort = _as_dict(data.get("effort")).get("level", "")
744
+ if effort:
745
+ effort_label = {
746
+ "xhigh": _t("effort_xhigh"),
747
+ "high": _t("effort_high"),
748
+ "normal": _t("effort_normal"),
749
+ "low": _t("effort_low"),
750
+ }.get(effort, effort)
751
+ model_name += f"/{effort_label}"
752
+ if data.get("fast_mode"):
753
+ model_name += f" {_t('fast_mode')}"
754
+ line3.append(f"{C['dim']}{C['magenta']}{safe_text(model_name)}{C['reset']}")
755
+
756
+ if vlen(" | ".join(line3)) > width and duration_part:
757
+ line3 = [p for p in line3 if p != duration_part]
758
+
759
+ update_version = _read_update_hint(now.timestamp())
760
+ if update_version and (line1 or line3):
761
+ line3.append(
762
+ f"{C['cyan']}🆕 v{safe_text(update_version)} "
763
+ f"{_t('update_available_suffix')}{C['reset']}"
764
+ )
765
+
766
+ output = [" | ".join(line) for line in (line1, line3) if line]
767
+ warning = _heavy_warning(data, now.timestamp())
768
+ if warning:
769
+ output.append(warning)
770
+ return "\n".join(output) if output else "usage"
771
+
772
+
773
+ def render(data: Dict[str, Any], now: datetime) -> str:
774
+ try:
775
+ return _render_core(data, now)
776
+ except Exception as exc:
777
+ _debug("render failed", exc)
778
+ return "usage"
779
+
780
+
781
+ def main() -> None:
782
+ _configure_windows_utf8_output()
783
+ try:
784
+ raw = _read_stdin_utf8()
785
+ except Exception as exc:
786
+ _debug("stdin read failed", exc)
787
+ return
788
+ if not raw.strip():
789
+ return
790
+ try:
791
+ data = json.loads(raw)
792
+ except json.JSONDecodeError as exc:
793
+ _debug("invalid stdin JSON", exc)
794
+ print("usage")
795
+ return
796
+ if not isinstance(data, dict):
797
+ _debug("stdin JSON root is not an object")
798
+ print("usage")
799
+ return
800
+ now = datetime.now(timezone.utc)
801
+ try:
802
+ save(data, now)
803
+ print(render(data, now))
804
+ except Exception as exc:
805
+ _debug("statusline failed", exc)
806
+ print("usage")
807
+
808
+
809
+ if __name__ == "__main__":
810
+ main()