token-tracker 0.4.4__tar.gz → 0.4.6__tar.gz

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 (48) hide show
  1. {token_tracker-0.4.4/src/token_tracker.egg-info → token_tracker-0.4.6}/PKG-INFO +8 -1
  2. {token_tracker-0.4.4 → token_tracker-0.4.6}/README.md +7 -0
  3. {token_tracker-0.4.4 → token_tracker-0.4.6}/pyproject.toml +1 -1
  4. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/hooks.py +118 -30
  5. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/ui/themes.py +4 -2
  6. {token_tracker-0.4.4 → token_tracker-0.4.6/src/token_tracker.egg-info}/PKG-INFO +8 -1
  7. {token_tracker-0.4.4 → token_tracker-0.4.6}/tests/test_hooks.py +98 -2
  8. {token_tracker-0.4.4 → token_tracker-0.4.6}/LICENSE +0 -0
  9. {token_tracker-0.4.4 → token_tracker-0.4.6}/setup.cfg +0 -0
  10. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/__init__.py +0 -0
  11. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/adapters/__init__.py +0 -0
  12. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/adapters/claude.py +0 -0
  13. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/adapters/codex.py +0 -0
  14. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/adapters/rate_limits.py +0 -0
  15. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/adapters/registry.py +0 -0
  16. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/adapters/types.py +0 -0
  17. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/adapters/util.py +0 -0
  18. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/analyzer/__init__.py +0 -0
  19. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/analyzer/aggregator.py +0 -0
  20. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/analyzer/blocks.py +0 -0
  21. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/analyzer/cost.py +0 -0
  22. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/cli.py +0 -0
  23. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/config.py +0 -0
  24. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/i18n.py +0 -0
  25. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/ui/__init__.py +0 -0
  26. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/ui/console.py +0 -0
  27. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/ui/format.py +0 -0
  28. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/ui/heatmap.py +0 -0
  29. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/ui/panels.py +0 -0
  30. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/ui/status.py +0 -0
  31. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/ui/tables.py +0 -0
  32. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/ui/theme.py +0 -0
  33. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker/wizard.py +0 -0
  34. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker.egg-info/SOURCES.txt +0 -0
  35. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker.egg-info/dependency_links.txt +0 -0
  36. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker.egg-info/entry_points.txt +0 -0
  37. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker.egg-info/requires.txt +0 -0
  38. {token_tracker-0.4.4 → token_tracker-0.4.6}/src/token_tracker.egg-info/top_level.txt +0 -0
  39. {token_tracker-0.4.4 → token_tracker-0.4.6}/tests/test_aggregator.py +0 -0
  40. {token_tracker-0.4.4 → token_tracker-0.4.6}/tests/test_blocks.py +0 -0
  41. {token_tracker-0.4.4 → token_tracker-0.4.6}/tests/test_cli.py +0 -0
  42. {token_tracker-0.4.4 → token_tracker-0.4.6}/tests/test_codex.py +0 -0
  43. {token_tracker-0.4.4 → token_tracker-0.4.6}/tests/test_cost.py +0 -0
  44. {token_tracker-0.4.4 → token_tracker-0.4.6}/tests/test_heatmap.py +0 -0
  45. {token_tracker-0.4.4 → token_tracker-0.4.6}/tests/test_status.py +0 -0
  46. {token_tracker-0.4.4 → token_tracker-0.4.6}/tests/test_tables.py +0 -0
  47. {token_tracker-0.4.4 → token_tracker-0.4.6}/tests/test_theme.py +0 -0
  48. {token_tracker-0.4.4 → token_tracker-0.4.6}/tests/test_util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: token-tracker
3
- Version: 0.4.4
3
+ Version: 0.4.6
4
4
  Summary: Track token usage across local AI agents (Claude Code, Codex)
5
5
  Author-email: stormzhang <stormzhang.dev@gmail.com>
6
6
  License: MIT License
@@ -149,6 +149,13 @@ curl -sSL https://raw.githubusercontent.com/stormzhang/token-tracker/main/instal
149
149
  > **升级**:重跑上面的命令即可(脚本幂等、自动升到最新)。
150
150
  > **卸载**:`tt unsetup`
151
151
 
152
+ **升级后 `tt --version` 还是旧版?** 多半是旧版装在别的 Python 环境里遮蔽了新版(常见于 Windows、或早期用 `pip install` 装过)。卸载旧版后重装一次即可:
153
+
154
+ ```bash
155
+ pip uninstall token-tracker
156
+ curl -sSL https://raw.githubusercontent.com/stormzhang/token-tracker/main/install.sh | bash
157
+ ```
158
+
152
159
  ## 使用
153
160
 
154
161
  ```bash
@@ -96,6 +96,13 @@ curl -sSL https://raw.githubusercontent.com/stormzhang/token-tracker/main/instal
96
96
  > **升级**:重跑上面的命令即可(脚本幂等、自动升到最新)。
97
97
  > **卸载**:`tt unsetup`
98
98
 
99
+ **升级后 `tt --version` 还是旧版?** 多半是旧版装在别的 Python 环境里遮蔽了新版(常见于 Windows、或早期用 `pip install` 装过)。卸载旧版后重装一次即可:
100
+
101
+ ```bash
102
+ pip uninstall token-tracker
103
+ curl -sSL https://raw.githubusercontent.com/stormzhang/token-tracker/main/install.sh | bash
104
+ ```
105
+
99
106
  ## 使用
100
107
 
101
108
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "token-tracker"
7
- version = "0.4.4"
7
+ version = "0.4.6"
8
8
  description = "Track token usage across local AI agents (Claude Code, Codex)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -36,8 +36,8 @@ CODEX_DIR = _CODEX
36
36
  CODEX_CONFIG = os.path.join(CODEX_DIR, "config.toml") # 改 Codex 配置,留 agent 目录
37
37
  CODEX_STATUSLINE_HOOK_PATH = os.path.join(_TT, "codex-statusline.py")
38
38
  STATUS_FILE = os.path.join(_TT, "tt-status.json") # CC statusline 缓存(脚本写、tt status 读)
39
- HOOK_VERSION = "1.7"
40
- STATUSLINE_HOOK_VERSION = "1.0"
39
+ HOOK_VERSION = "1.8"
40
+ STATUSLINE_HOOK_VERSION = "1.1"
41
41
 
42
42
  CC_BACKUP_PATH = os.path.join(_TT, "cc-backup.json")
43
43
  CODEX_BACKUP_LEGACY = os.path.join(_TT, "codex-backup.json") # 老用户残留,unsetup 时还能恢复
@@ -150,25 +150,35 @@ def git_branch(cwd):
150
150
 
151
151
 
152
152
  def git_diff_stat(cwd):
153
- """相对 HEAD 的未提交增删行数(暂存+未暂存,不含未跟踪文件)。失败/无 commit 返回 (0, 0)。"""
153
+ """相对 HEAD 的未提交增删行数 + 未跟踪文件数(已跟踪改动按行、未跟踪按文件计数)。
154
+ 失败/无 commit 返回 (0, 0, 0)。"""
155
+ added = deleted = 0
154
156
  try:
155
157
  out = subprocess.check_output(
156
158
  ["git", "diff", "HEAD", "--numstat"], cwd=cwd,
157
159
  stderr=subprocess.DEVNULL, text=True, timeout=2,
158
160
  )
161
+ for line in out.splitlines():
162
+ parts = line.split("\t")
163
+ if len(parts) < 2:
164
+ continue
165
+ a, d = parts[0], parts[1]
166
+ if a.isdigit():
167
+ added += int(a)
168
+ if d.isdigit():
169
+ deleted += int(d)
159
170
  except Exception:
160
- return 0, 0
161
- added = deleted = 0
162
- for line in out.splitlines():
163
- parts = line.split("\t")
164
- if len(parts) < 2:
165
- continue
166
- a, d = parts[0], parts[1]
167
- if a.isdigit():
168
- added += int(a)
169
- if d.isdigit():
170
- deleted += int(d)
171
- return added, deleted
171
+ pass
172
+ untracked = 0
173
+ try:
174
+ out = subprocess.check_output(
175
+ ["git", "ls-files", "--others", "--exclude-standard"], cwd=cwd,
176
+ stderr=subprocess.DEVNULL, text=True, timeout=2,
177
+ )
178
+ untracked = sum(1 for ln in out.splitlines() if ln.strip())
179
+ except Exception:
180
+ pass
181
+ return added, deleted, untracked
172
182
 
173
183
 
174
184
  def save_data(data, now):
@@ -267,11 +277,13 @@ def render(data, now, tps=None):
267
277
  branch = git_branch(project)
268
278
  if branch:
269
279
  inner = f"{C['branch']}{branch}{C['reset']}"
270
- added, deleted = git_diff_stat(project)
280
+ added, deleted, untracked = git_diff_stat(project)
271
281
  if added:
272
282
  inner += f" {C['added']}+{added}{C['reset']}"
273
283
  if deleted:
274
284
  inner += f" {C['deleted']}-{deleted}{C['reset']}"
285
+ if untracked:
286
+ inner += f" {C['untracked']}?{untracked}{C['reset']}"
275
287
  line1.append(f"\033[1m{C['project']}[{name}]{C['reset']}({inner})")
276
288
  else:
277
289
  line1.append(f"\033[1m{C['project']}[{name}]{C['reset']}")
@@ -528,9 +540,10 @@ def _ctx_pct(info):
528
540
 
529
541
 
530
542
  def _git_status(cwd):
531
- """(branch, +A, -D);失败/非 git/无 commit 返回 ("", 0, 0)。"""
543
+ """(branch, +A, -D, ?U);已跟踪改动按行、未跟踪按文件计数。
544
+ 失败/非 git/无 commit 返回 ("", 0, 0, 0)。"""
532
545
  if not cwd:
533
- return "", 0, 0
546
+ return "", 0, 0, 0
534
547
 
535
548
  def run(args):
536
549
  return subprocess.check_output(
@@ -540,9 +553,9 @@ def _git_status(cwd):
540
553
  try:
541
554
  branch = run(["branch", "--show-current"])
542
555
  except Exception:
543
- return "", 0, 0
556
+ return "", 0, 0, 0
544
557
  if not branch:
545
- return "", 0, 0
558
+ return "", 0, 0, 0
546
559
  try:
547
560
  if run(["status", "--porcelain", "--untracked-files=no"]):
548
561
  branch += "*"
@@ -559,14 +572,19 @@ def _git_status(cwd):
559
572
  d += int(parts[1])
560
573
  except Exception:
561
574
  pass
562
- return branch, a, d
575
+ u = 0
576
+ try:
577
+ u = sum(1 for ln in run(["ls-files", "--others", "--exclude-standard"]).splitlines() if ln.strip())
578
+ except Exception:
579
+ pass
580
+ return branch, a, d, u
563
581
 
564
582
 
565
583
  def _render_project(cwd):
566
584
  if not cwd:
567
585
  return ""
568
586
  name = os.path.basename(cwd.rstrip("/"))
569
- branch, a, d = _git_status(cwd)
587
+ branch, a, d, u = _git_status(cwd)
570
588
  if not branch:
571
589
  return f"{BOLD}{C['project']}[{name}]{RST}"
572
590
  inner = f"{C['branch']}{branch}{RST}"
@@ -574,6 +592,8 @@ def _render_project(cwd):
574
592
  inner += f" {C['added']}+{a}{RST}"
575
593
  if d:
576
594
  inner += f" {C['deleted']}-{d}{RST}"
595
+ if u:
596
+ inner += f" {C['untracked']}?{u}{RST}"
577
597
  return f"{BOLD}{C['project']}[{name}]{RST}({inner})"
578
598
 
579
599
 
@@ -614,7 +634,8 @@ def main():
614
634
  line1.append(f"{C['tokens']}Total: {fmt_tokens(total)}{RST}") # 整体取 tokens 槽(mocha=peach/橙)
615
635
  model = model or payload.get("model") or "" # session turn_context 的 model(gpt-5.5)优先
616
636
  if model:
617
- label = f"{model} {effort}" if effort else model
637
+ # effort 缺失时显示 default(Codex 默认 reasoning level),与 TUI 的 Current reasoning level 对齐
638
+ label = f"{model} {effort or 'default'}"
618
639
  line1.append(f"{C['total']}Model: {label}{RST}") # 整体取 total 槽(mocha=red/红)
619
640
 
620
641
  # L2: Limit: 5h | 7d | <window> Ctx(仿 CC statusline,带进度条 + reset)
@@ -702,13 +723,21 @@ def _has_tt_codex_statusline(content: str) -> bool:
702
723
 
703
724
  def _install_codex_statusline(content: str, python: str) -> str:
704
725
  """落盘 Codex statusline 脚本 + 在 config.toml 末尾追加 Stop hook 段。
705
- 新名(codex-statusline)已存在幂等返回;只存在旧名(tt-statusline)→ 清掉后追加(迁移)。"""
726
+ - 新名 codex-statusline 段已存在 + python 路径一致 幂等返回;
727
+ - 已存在但 python 路径不一致(用户升级 Python / 切换 conda/venv / 卸载某环境后跑 tt setup)→ 删旧装新,
728
+ 避免 command 指向已死 python(症状:脚本 import token_tracker 失败被 try/except 吞掉,状态栏只剩项目名);
729
+ - 只存在旧名 tt-statusline → 清掉后追加新路径段(老用户迁移)。"""
706
730
  _write_codex_statusline_script()
707
- if "codex-statusline" in content:
708
- return content
709
- if "tt-statusline" in content: # 旧路径残留 → 清掉,下面追加新路径段
710
- content = _CODEX_STATUSLINE_REGEX.sub("\n", content)
711
731
  cmd = f"{python} {CODEX_STATUSLINE_HOOK_PATH}"
732
+ if "codex-statusline" in content or "tt-statusline" in content:
733
+ # 从已有的 command 行提取 python 路径(兼容 basic / literal string 包裹)
734
+ m = re.search(
735
+ r"command = [\"']([^ ]+) [^\"']*(?:codex-statusline|tt-statusline)[^\"']*[\"']",
736
+ content,
737
+ )
738
+ if m and m.group(1) == python and "codex-statusline" in content:
739
+ return content # python 一致 + 已是新名 → 幂等
740
+ content = _CODEX_STATUSLINE_REGEX.sub("\n", content) # 路径不一致或老名残留 → 删旧段
712
741
  return content.rstrip() + (
713
742
  "\n\n[[hooks.Stop]]\n\n"
714
743
  "[[hooks.Stop.hooks]]\n"
@@ -791,6 +820,30 @@ def _is_tt_cc_command(cmd: str) -> bool:
791
820
  return "claude-statusline" in cmd or "tt-statusline" in cmd
792
821
 
793
822
 
823
+ def _build_cc_command(python: str, script: str) -> str:
824
+ """拼 statusLine command 字符串。
825
+ Windows: 反斜杠转正斜杠(CC 在 Windows 走 Git Bash/sh 执行 command,反斜杠被吞致 exit 127);
826
+ 所有平台: 两段路径都加双引号包裹(防路径含空格断词)。
827
+ issue #13 / #14 根治:旧格式 `f"{python} {script}"` 在 Windows 静默失败、状态栏空白。"""
828
+ if os.name == "nt":
829
+ python = python.replace("\\", "/")
830
+ script = script.replace("\\", "/")
831
+ return f'"{python}" "{script}"'
832
+
833
+
834
+ def _cc_command_outdated(cmd: str) -> bool:
835
+ """settings.json 里 tt 的 statusLine.command 是否还是旧格式(裸拼接 / 含反斜杠)。
836
+ 新格式:两段路径都用 `"` 包裹 + Windows 上路径必须正斜杠。
837
+ 仅对 tt 的 command 生效(_is_tt_cc_command 已先过滤),用户原 command 不动。"""
838
+ if not cmd:
839
+ return False
840
+ if not cmd.startswith('"'):
841
+ return True # 没引号 = 旧裸拼接
842
+ if os.name == "nt" and "\\" in cmd:
843
+ return True # Windows 上还含反斜杠 = 没转过来
844
+ return False
845
+
846
+
794
847
  def _write_cc_statusline_script() -> None:
795
848
  """渲染并落盘 CC statusline 脚本(mkdir + 执行权限)。"""
796
849
  os.makedirs(_TT, exist_ok=True)
@@ -811,12 +864,45 @@ def _migrate_legacy() -> None:
811
864
  pass
812
865
 
813
866
 
867
+ def _cc_command_needs_sync() -> bool:
868
+ """检测 settings.json 里 tt 的 statusLine.command 是否需要重写为新格式(issue #13/#14)。
869
+ 用户原 command(非 tt)一律不动。"""
870
+ if not os.path.exists(CLAUDE_SETTINGS):
871
+ return False
872
+ try:
873
+ with open(CLAUDE_SETTINGS, encoding="utf-8") as f:
874
+ settings = json.load(f)
875
+ except (OSError, json.JSONDecodeError):
876
+ return False
877
+ cmd = (settings.get("statusLine") or {}).get("command") or ""
878
+ if not _is_tt_cc_command(cmd):
879
+ return False
880
+ return _cc_command_outdated(cmd)
881
+
882
+
883
+ def _sync_cc_command() -> None:
884
+ """重写 settings.json 里 tt 的 statusLine.command 字段(保留其它字段不动)。"""
885
+ try:
886
+ with open(CLAUDE_SETTINGS, encoding="utf-8") as f:
887
+ settings = json.load(f)
888
+ except (OSError, json.JSONDecodeError):
889
+ return
890
+ if not _is_tt_cc_command((settings.get("statusLine") or {}).get("command") or ""):
891
+ return
892
+ python = sys.executable or "python3"
893
+ settings["statusLine"] = {"type": "command", "command": _build_cc_command(python, HOOK_SCRIPT_PATH)}
894
+ with open(CLAUDE_SETTINGS, "w", encoding="utf-8") as f:
895
+ json.dump(settings, f, indent=2, ensure_ascii=False)
896
+
897
+
814
898
  def needs_update() -> bool:
815
899
  # 只在已安装(新位置脚本文件存在)时纳入版本判断,未装不主动装
816
900
  if os.path.exists(HOOK_SCRIPT_PATH) and _installed_hook_version() != HOOK_VERSION:
817
901
  return True
818
902
  sv = _installed_codex_statusline_version()
819
- return sv is not None and sv != STATUSLINE_HOOK_VERSION
903
+ if sv is not None and sv != STATUSLINE_HOOK_VERSION:
904
+ return True
905
+ return _cc_command_needs_sync() # settings.json 里 command 格式过时也算待更新(issue #13/#14)
820
906
 
821
907
 
822
908
  def update_hook() -> None:
@@ -824,6 +910,8 @@ def update_hook() -> None:
824
910
  _write_cc_statusline_script()
825
911
  if _installed_codex_statusline_version() is not None:
826
912
  _write_codex_statusline_script()
913
+ if _cc_command_needs_sync():
914
+ _sync_cc_command()
827
915
 
828
916
 
829
917
  # --- setup ---
@@ -895,7 +983,7 @@ def _setup_claude(quiet: bool = False) -> None:
895
983
  json.dump({"statusLine": existing}, f, indent=2)
896
984
 
897
985
  python = sys.executable or "python3"
898
- settings["statusLine"] = {"type": "command", "command": f"{python} {HOOK_SCRIPT_PATH}"}
986
+ settings["statusLine"] = {"type": "command", "command": _build_cc_command(python, HOOK_SCRIPT_PATH)}
899
987
 
900
988
  with open(CLAUDE_SETTINGS, "w", encoding="utf-8") as f:
901
989
  json.dump(settings, f, indent=2, ensure_ascii=False)
@@ -2,7 +2,7 @@
2
2
 
3
3
  每个主题只定义 **9 个基色**(沿用 Catppuccin 的槽位命名)+ 是否浅色 + 5 档热力渐变;
4
4
  - 17 个 CLI 语义槽由 `derive_slots()` 统一派生(`token=sapphire`、`cost=yellow`…);
5
- - statusline 的 12 个 key 由 `_STATUSLINE_SLOTS` 映射到这 9 槽的子集。
5
+ - statusline 的 13 个 key 由 `_STATUSLINE_SLOTS` 映射到这 9 槽的子集。
6
6
 
7
7
  颜色取值:Catppuccin(mocha/latte/frappe/macchiato)用官方 hex;Nord / Dracula 按色相
8
8
  就近映射到这 9 槽。truecolor 终端用精确 hex,不支持 truecolor 的降级到 **256 色近似**
@@ -77,15 +77,17 @@ THEMES: dict[str, dict] = {
77
77
  # 展示顺序(tt theme list / 向导)。
78
78
  THEME_NAMES = tuple(THEMES)
79
79
 
80
- # statusline 12 个着色 key → 9 基色槽(reset 固定为 \033[0m,单列)。
80
+ # statusline 13 个着色 key → 9 基色槽(reset 固定为 \033[0m,单列)。
81
81
  # 这套角色映射沿用旧 mocha 状态栏观感(分支红/玫红、标签粉、Tokens 桃、Model/Duration 蓝),
82
82
  # 与 CLI 报表的 _S 语义槽**不完全同源**(CLI token=sapphire 青)——按主人审美选择保留状态栏旧手感。
83
83
  # total=red 红:第 1 行消耗/产出指标(Total/Cost/Code 标签)用它。
84
+ # untracked=mauve 紫:git 段未跟踪文件数 ?N,独占一色与 +A(绿)/-D(红) 区分。
84
85
  _STATUSLINE_SLOTS = {
85
86
  "project": "green",
86
87
  "branch": "red",
87
88
  "added": "green",
88
89
  "deleted": "red",
90
+ "untracked": "mauve",
89
91
  "label": "pink",
90
92
  "bar_ok": "green",
91
93
  "bar_warn": "yellow",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: token-tracker
3
- Version: 0.4.4
3
+ Version: 0.4.6
4
4
  Summary: Track token usage across local AI agents (Claude Code, Codex)
5
5
  Author-email: stormzhang <stormzhang.dev@gmail.com>
6
6
  License: MIT License
@@ -149,6 +149,13 @@ curl -sSL https://raw.githubusercontent.com/stormzhang/token-tracker/main/instal
149
149
  > **升级**:重跑上面的命令即可(脚本幂等、自动升到最新)。
150
150
  > **卸载**:`tt unsetup`
151
151
 
152
+ **升级后 `tt --version` 还是旧版?** 多半是旧版装在别的 Python 环境里遮蔽了新版(常见于 Windows、或早期用 `pip install` 装过)。卸载旧版后重装一次即可:
153
+
154
+ ```bash
155
+ pip uninstall token-tracker
156
+ curl -sSL https://raw.githubusercontent.com/stormzhang/token-tracker/main/install.sh | bash
157
+ ```
158
+
152
159
  ## 使用
153
160
 
154
161
  ```bash
@@ -80,12 +80,28 @@ def test_codex_statusline_install_uninstall_roundtrip(tmp_path, monkeypatch):
80
80
  assert "tt-statusline" not in removed and 'command = "mine"' in removed
81
81
 
82
82
 
83
+ def test_codex_statusline_install_updates_stale_python(tmp_path, monkeypatch):
84
+ # 回归:用户升级 Python / 切换 conda/venv 后再跑 tt setup,
85
+ # 老的 codex-statusline 段被无脑幂等保留 → command 指向已死 python → 状态栏半残(issue 用户反馈)。
86
+ monkeypatch.setattr(hooks, "CODEX_STATUSLINE_HOOK_PATH", str(tmp_path / "codex-statusline.py"))
87
+ first = hooks._install_codex_statusline("", "/old/python")
88
+ assert "/old/python" in first
89
+ # 同 python 再装 → 幂等保持
90
+ assert hooks._install_codex_statusline(first, "/old/python") == first
91
+ # 换 python(用户升级了 Python / 切了环境)→ 必须重写、删旧段、装新段
92
+ second = hooks._install_codex_statusline(first, "/new/python")
93
+ assert "/new/python" in second
94
+ assert "/old/python" not in second # 死路径清干净
95
+ assert second.count("[[hooks.Stop]]") == 1 # 没残留两段
96
+
97
+
83
98
  def test_codex_statusline_windows_path_toml_parses(tmp_path, monkeypatch):
84
99
  # 回归:Windows 路径含 \U \A \P 等被 TOML basic string 当 unicode 转义起始符(issue 用户反馈)
85
100
  # —— 写入的 command 必须用 literal string(单引号)包裹,写出来后能被 tomllib 原样解析回来。
86
101
  import tomllib
87
102
  monkeypatch.setattr(hooks, "CODEX_STATUSLINE_HOOK_PATH",
88
103
  r"C:\Users\test\.config\token-tracker\codex-statusline.py")
104
+ monkeypatch.setattr(hooks, "_write_codex_statusline_script", lambda: None) # 别在 macOS 上真写 Windows 路径
89
105
  py = r"C:\Users\test\AppData\Local\Programs\Python\Python313\python.exe"
90
106
  content = hooks._install_codex_statusline("", py)
91
107
  parsed = tomllib.loads(content) # 旧 bug:basic string 下 \U 等触发 TOML 解析错误
@@ -128,8 +144,8 @@ def test_setup_components_off_skips_install(tmp_path, monkeypatch):
128
144
 
129
145
  hooks.setup(components=hooks.SetupComponents(codex_faux_statusline=False))
130
146
 
131
- # CC statusline 仍装
132
- assert json.loads(settings_path.read_text())["statusLine"]["command"].endswith("tt-statusline.py")
147
+ # CC statusline 仍装(command 现在带引号包裹,issue #13 修复)
148
+ assert json.loads(settings_path.read_text())["statusLine"]["command"].endswith('tt-statusline.py"')
133
149
  # Codex 端:不再动 [tui].status_line(保持用户原配置);Stop hook(tt-statusline)也不在 config 里
134
150
  codex_content = codex_config.read_text()
135
151
  assert "status_line = []" in codex_content # 用户原 status_line 没被动
@@ -213,6 +229,12 @@ def test_statusline_shows_git_diff_stat(tmp_path):
213
229
  line_dirty = _run_statusline(script_path, repo)
214
230
  assert "+2" in line_dirty and "-1" in line_dirty
215
231
 
232
+ # 再造 2 个未跟踪文件 → 应额外显示 ?2(按文件数计、不读行数)
233
+ (repo / "new1.txt").write_text("x\n")
234
+ (repo / "new2.txt").write_text("x\ny\n")
235
+ line_with_untracked = _run_statusline(script_path, repo)
236
+ assert "?2" in line_with_untracked
237
+
216
238
 
217
239
  def _run_statusline_home(script_path, payload, home):
218
240
  """隔离 HOME 下跑落盘 statusline 脚本,返回完整 stdout(不污染真实 ~/.claude)。"""
@@ -464,3 +486,77 @@ def test_cli_setup_up_to_date_skips_flow(monkeypatch, tmp_path):
464
486
 
465
487
  cli.main()
466
488
  assert calls == {}
489
+
490
+
491
+ def test_build_cc_command_windows_quotes_and_slashes(monkeypatch):
492
+ # issue #13/#14:Windows 上 statusLine command 必须正斜杠 + 引号包裹,
493
+ # 否则 CC 走 Git Bash 执行时反斜杠被吞,状态栏静默空白。
494
+ monkeypatch.setattr(hooks.os, "name", "nt")
495
+ cmd = hooks._build_cc_command(
496
+ r"C:\Users\X\pipx\venvs\token-tracker\Scripts\python.exe",
497
+ r"C:\Users\X\.config\token-tracker\claude-statusline.py",
498
+ )
499
+ assert cmd == '"C:/Users/X/pipx/venvs/token-tracker/Scripts/python.exe" "C:/Users/X/.config/token-tracker/claude-statusline.py"'
500
+ assert "\\" not in cmd # 反斜杠全转完
501
+ assert cmd.count('"') == 4 # 两段路径各包一对引号
502
+
503
+
504
+ def test_build_cc_command_unix_always_quoted(monkeypatch):
505
+ # Unix 平台不转换路径分隔符,但始终加引号(防路径含空格断词)。
506
+ monkeypatch.setattr(hooks.os, "name", "posix")
507
+ cmd = hooks._build_cc_command(
508
+ "/Users/John Doe/.local/share/uv/tools/token-tracker/bin/python3",
509
+ "/Users/John Doe/.config/token-tracker/claude-statusline.py",
510
+ )
511
+ assert cmd.startswith('"') and cmd.count('"') == 4
512
+ assert "John Doe" in cmd # 含空格路径被引号包住、能正确执行
513
+
514
+
515
+ def test_cc_command_outdated_detects_legacy_format(monkeypatch):
516
+ # 旧格式(裸拼接、无引号)应被检测为过时;新格式不动。
517
+ monkeypatch.setattr(hooks.os, "name", "posix")
518
+ assert hooks._cc_command_outdated("/usr/bin/python3 /home/u/.config/token-tracker/claude-statusline.py")
519
+ assert not hooks._cc_command_outdated('"/usr/bin/python3" "/home/u/.config/token-tracker/claude-statusline.py"')
520
+ # Windows 上即便有引号,含反斜杠也算过时
521
+ monkeypatch.setattr(hooks.os, "name", "nt")
522
+ assert hooks._cc_command_outdated(r'"C:\Users\X\python.exe" "C:\Users\X\claude-statusline.py"')
523
+ assert not hooks._cc_command_outdated('"C:/Users/X/python.exe" "C:/Users/X/claude-statusline.py"')
524
+ # 空命令 / 非 tt 命令交给上层 _is_tt_cc_command 过滤;这里仅断言空串返回 False
525
+ assert not hooks._cc_command_outdated("")
526
+
527
+
528
+ def test_update_hook_rewrites_outdated_cc_command(tmp_path, monkeypatch):
529
+ # 老用户场景:HOOK_SCRIPT_PATH 存在 + settings.json 里 command 是旧格式 →
530
+ # 跑任意 tt 命令触发 update_hook 自动重写为新格式(用户其它字段不动)。
531
+ settings_file = tmp_path / "settings.json"
532
+ script_file = tmp_path / "claude-statusline.py"
533
+ script_file.write_text(hooks._render_hook_script(), encoding="utf-8")
534
+ settings_file.write_text(json.dumps({
535
+ "statusLine": {"type": "command",
536
+ "command": "/old/python3 /old/path/claude-statusline.py"},
537
+ "userField": "keep me",
538
+ }), encoding="utf-8")
539
+ monkeypatch.setattr(hooks, "CLAUDE_SETTINGS", str(settings_file))
540
+ monkeypatch.setattr(hooks, "HOOK_SCRIPT_PATH", str(script_file))
541
+ monkeypatch.setattr(hooks.sys, "executable", "/new/python3")
542
+ monkeypatch.setattr(hooks.os, "name", "posix")
543
+
544
+ assert hooks._cc_command_needs_sync() # 检测到过时
545
+ hooks.update_hook()
546
+ new_settings = json.loads(settings_file.read_text(encoding="utf-8"))
547
+ assert new_settings["statusLine"]["command"].startswith('"/new/python3"')
548
+ assert new_settings["userField"] == "keep me" # 用户其它字段保留
549
+ assert not hooks._cc_command_needs_sync() # 重写后不再触发
550
+
551
+
552
+ def test_cc_command_sync_skips_non_tt_command(tmp_path, monkeypatch):
553
+ # 用户自己的 statusLine(非 tt)即便没引号也不动——只管 tt 自己装的。
554
+ settings_file = tmp_path / "settings.json"
555
+ user_cmd = "/usr/bin/my-own-statusline --foo"
556
+ settings_file.write_text(json.dumps({
557
+ "statusLine": {"type": "command", "command": user_cmd},
558
+ }), encoding="utf-8")
559
+ monkeypatch.setattr(hooks, "CLAUDE_SETTINGS", str(settings_file))
560
+ assert not hooks._cc_command_needs_sync()
561
+ hooks._sync_cc_command() # no-op
562
+ assert json.loads(settings_file.read_text(encoding="utf-8"))["statusLine"]["command"] == user_cmd
File without changes
File without changes