token-tracker 0.4.7__tar.gz → 0.4.8__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 (57) hide show
  1. {token_tracker-0.4.7/src/token_tracker.egg-info → token_tracker-0.4.8}/PKG-INFO +22 -4
  2. {token_tracker-0.4.7 → token_tracker-0.4.8}/README.md +18 -3
  3. {token_tracker-0.4.7 → token_tracker-0.4.8}/pyproject.toml +7 -1
  4. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/adapters/claude.py +3 -1
  5. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/adapters/codex.py +3 -1
  6. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/adapters/util.py +15 -0
  7. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/analyzer/cost.py +2 -1
  8. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/cli.py +131 -92
  9. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/config.py +4 -1
  10. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/hooks.py +5 -2
  11. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/i18n.py +36 -4
  12. token_tracker-0.4.8/src/token_tracker/sidebar.py +699 -0
  13. token_tracker-0.4.8/src/token_tracker/sidebar_events.py +120 -0
  14. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/templates/claude_statusline.py +42 -10
  15. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/templates/codex_statusline.py +79 -9
  16. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/ui/heatmap.py +5 -5
  17. token_tracker-0.4.8/src/token_tracker/ui/report_stats.py +56 -0
  18. token_tracker-0.4.8/src/token_tracker/ui/sidebar.py +303 -0
  19. token_tracker-0.4.8/src/token_tracker/ui/sidebar_app.py +466 -0
  20. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/ui/tables.py +6 -59
  21. {token_tracker-0.4.7 → token_tracker-0.4.8/src/token_tracker.egg-info}/PKG-INFO +22 -4
  22. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker.egg-info/SOURCES.txt +7 -0
  23. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker.egg-info/requires.txt +3 -0
  24. {token_tracker-0.4.7 → token_tracker-0.4.8}/tests/test_cli.py +10 -3
  25. {token_tracker-0.4.7 → token_tracker-0.4.8}/tests/test_cost.py +7 -0
  26. {token_tracker-0.4.7 → token_tracker-0.4.8}/tests/test_heatmap.py +3 -1
  27. {token_tracker-0.4.7 → token_tracker-0.4.8}/tests/test_hooks.py +126 -1
  28. token_tracker-0.4.8/tests/test_sidebar.py +929 -0
  29. token_tracker-0.4.8/tests/test_sidebar_app.py +380 -0
  30. {token_tracker-0.4.7 → token_tracker-0.4.8}/tests/test_status.py +3 -3
  31. token_tracker-0.4.8/tests/test_util.py +83 -0
  32. token_tracker-0.4.7/tests/test_util.py +0 -34
  33. {token_tracker-0.4.7 → token_tracker-0.4.8}/LICENSE +0 -0
  34. {token_tracker-0.4.7 → token_tracker-0.4.8}/setup.cfg +0 -0
  35. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/__init__.py +0 -0
  36. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/adapters/__init__.py +0 -0
  37. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/adapters/rate_limits.py +0 -0
  38. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/adapters/registry.py +0 -0
  39. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/adapters/types.py +0 -0
  40. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/analyzer/__init__.py +0 -0
  41. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/analyzer/aggregator.py +0 -0
  42. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/templates/__init__.py +0 -0
  43. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/tz.py +0 -0
  44. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/ui/__init__.py +0 -0
  45. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/ui/console.py +0 -0
  46. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/ui/format.py +0 -0
  47. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/ui/status.py +0 -0
  48. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/ui/theme.py +0 -0
  49. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/ui/themes.py +0 -0
  50. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker/wizard.py +0 -0
  51. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker.egg-info/dependency_links.txt +0 -0
  52. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker.egg-info/entry_points.txt +0 -0
  53. {token_tracker-0.4.7 → token_tracker-0.4.8}/src/token_tracker.egg-info/top_level.txt +0 -0
  54. {token_tracker-0.4.7 → token_tracker-0.4.8}/tests/test_aggregator.py +0 -0
  55. {token_tracker-0.4.7 → token_tracker-0.4.8}/tests/test_codex.py +0 -0
  56. {token_tracker-0.4.7 → token_tracker-0.4.8}/tests/test_tables.py +0 -0
  57. {token_tracker-0.4.7 → token_tracker-0.4.8}/tests/test_theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: token-tracker
3
- Version: 0.4.7
3
+ Version: 0.4.8
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
@@ -43,10 +43,13 @@ Description-Content-Type: text/markdown
43
43
  License-File: LICENSE
44
44
  Requires-Dist: rich>=13.7
45
45
  Requires-Dist: questionary<3,>=2.0
46
+ Requires-Dist: textual>=1.0
46
47
  Provides-Extra: test
47
48
  Requires-Dist: pytest>=8.0; extra == "test"
49
+ Requires-Dist: pytest-asyncio>=0.24; extra == "test"
48
50
  Provides-Extra: dev
49
51
  Requires-Dist: pytest>=8.0; extra == "dev"
52
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
50
53
  Requires-Dist: ruff>=0.6; extra == "dev"
51
54
  Requires-Dist: mypy>=1.8; extra == "dev"
52
55
  Dynamic: license-file
@@ -67,6 +70,7 @@ Dynamic: license-file
67
70
 
68
71
  - **多 Agent 统一追踪** — Claude Code + Codex 统一读取,多 Agent 按来源分组
69
72
  - **状态栏集成** — Claude Code 用官方 StatusLine 接口;**Codex 业界首创伪 statusline 方案**(hook 注入两行真彩色状态栏,把官方未开放的能力在 Codex 里做了出来)
73
+ - **实时侧边栏** — `tt sidebar` 窄窗格常驻面板:全部活跃会话一屏总览(状态灯 + 最近提示词 + 「下一步」建议),点击会话直达对应 iTerm2 / tmux 窗格
70
74
  - **限额监控** — 实时 5h / 7d 配额百分比 + 重置倒计时
71
75
  - **多维成本分析** — 会话 / 日 / 周 / 月多维报表,等效成本统计
72
76
  - **定价识别** — litellm 在线定价 + 内置官方价双层兜底,覆盖 Claude / OpenAI / Gemini / Grok 及国产主流(Kimi / GLM / Qwen / 豆包 / DeepSeek / MiniMax / MiMo);新模型自动套用同系列定价、不静默归零
@@ -124,7 +128,7 @@ Codex 官方暂不支持自定义 StatusLine。Token Tracker 通过 hook 注入
124
128
 
125
129
  ## 报表速览
126
130
 
127
- `tt status` — 过去 5h 实时面板(合并概览 + 5h/7d 额度 + 近期会话)
131
+ `tt status` — 今日实时面板(今日合并概览 + 5h/7d 额度 + 今日会话)
128
132
 
129
133
  ![Status](assets/screenshot.png)
130
134
 
@@ -140,6 +144,19 @@ Codex 官方暂不支持自定义 StatusLine。Token Tracker 通过 hook 注入
140
144
 
141
145
  ![Sessions](assets/screenshot-sessions.png)
142
146
 
147
+ ## 实时侧边栏(`tt sidebar`)
148
+
149
+ 在终端分屏 / tmux 窄窗格里常驻,一屏总览本机所有 AI 会话:
150
+
151
+ - **活跃会话列表** — 过去 5h 内有动静的会话(Claude Code + Codex),按最近活动排序取前 10;头行显示状态灯、`项目名(分支)`、agent、模型与距上次活动时间
152
+ - **三地时钟** — 北京、洛杉矶、伦敦合并为一行,只显示城市与 `HH:MM`
153
+ - **提示词历史** — 运行中、待确认、等输入会话保留最近 5 条,历史最多 1 行、最新最多 2 行;空闲会话只显示最新 1 条,不显示「下一步」
154
+ - **状态灯** — 运行中(星形动画)/ 需要关注(工具调用无结果,大概率在等授权)/ 等输入 / 空闲
155
+ - **「下一步」建议** — 从 AI 最后一条回复的收尾段提取征询 / 待办(纯规则,不走模型);AI 正在用 AskUserQuestion 提问时直接显示问题与选项
156
+ - **点击跳转** — 点会话头行直接切换到它所在的 iTerm2 / tmux 窗格(需启用对应 Agent 的状态栏组件提供映射;Codex 在下一次回答完成后建立映射;iTerm2 首次点击会弹 macOS 自动化授权,属预期)
157
+
158
+ 操作:鼠标拖拽选择文字,松开后自动复制;滚轮 / 方向键 / PgUp/PgDn 滚动,`q` / `Esc` / `Ctrl+C` 退出;`tt sidebar --once` 打印一帧快照即退。数据每 5s 刷新,只读本地会话记录、不写任何产物;加 `--claude` / `--codex` 可只看单个 agent。
159
+
143
160
  ## 安装
144
161
 
145
162
  ```bash
@@ -164,10 +181,11 @@ curl -sSL https://raw.githubusercontent.com/stormzhang/token-tracker/main/instal
164
181
  tt setup # 交互配置向导(终端:上下键选语言 / 主题 / 各组件);非 tty 环境按推荐默认自动配置
165
182
  tt # 过去一年 token 热力图 + 顶部三段概览(= tt daily)
166
183
  tt daily # 同上(tt 无参即进 daily)
167
- tt status # 过去 5h 实时面板
184
+ tt status # 今日消耗、5h/7d 额度与今日会话
168
185
  tt weekly # 周报
169
186
  tt monthly # 月报
170
- tt sessions # 最近 20 条会话明细(tt sessions <n> 改条数、--sort 改排序)
187
+ tt sessions # 最近 20 条会话明细(tt sessions <正整数> 改条数、--sort 改排序)
188
+ tt sidebar # 常驻侧边栏:活跃会话总览 + 提示词历史 + 状态灯 + 点击跳转(--once 一帧快照)
171
189
  tt theme # 查看 / 切换配色主题(show / list / set / preview)
172
190
  tt unsetup # 卸载并恢复安装前的配置
173
191
  tt --version # 查看版本(-v / -V 同义)
@@ -14,6 +14,7 @@
14
14
 
15
15
  - **多 Agent 统一追踪** — Claude Code + Codex 统一读取,多 Agent 按来源分组
16
16
  - **状态栏集成** — Claude Code 用官方 StatusLine 接口;**Codex 业界首创伪 statusline 方案**(hook 注入两行真彩色状态栏,把官方未开放的能力在 Codex 里做了出来)
17
+ - **实时侧边栏** — `tt sidebar` 窄窗格常驻面板:全部活跃会话一屏总览(状态灯 + 最近提示词 + 「下一步」建议),点击会话直达对应 iTerm2 / tmux 窗格
17
18
  - **限额监控** — 实时 5h / 7d 配额百分比 + 重置倒计时
18
19
  - **多维成本分析** — 会话 / 日 / 周 / 月多维报表,等效成本统计
19
20
  - **定价识别** — litellm 在线定价 + 内置官方价双层兜底,覆盖 Claude / OpenAI / Gemini / Grok 及国产主流(Kimi / GLM / Qwen / 豆包 / DeepSeek / MiniMax / MiMo);新模型自动套用同系列定价、不静默归零
@@ -71,7 +72,7 @@ Codex 官方暂不支持自定义 StatusLine。Token Tracker 通过 hook 注入
71
72
 
72
73
  ## 报表速览
73
74
 
74
- `tt status` — 过去 5h 实时面板(合并概览 + 5h/7d 额度 + 近期会话)
75
+ `tt status` — 今日实时面板(今日合并概览 + 5h/7d 额度 + 今日会话)
75
76
 
76
77
  ![Status](assets/screenshot.png)
77
78
 
@@ -87,6 +88,19 @@ Codex 官方暂不支持自定义 StatusLine。Token Tracker 通过 hook 注入
87
88
 
88
89
  ![Sessions](assets/screenshot-sessions.png)
89
90
 
91
+ ## 实时侧边栏(`tt sidebar`)
92
+
93
+ 在终端分屏 / tmux 窄窗格里常驻,一屏总览本机所有 AI 会话:
94
+
95
+ - **活跃会话列表** — 过去 5h 内有动静的会话(Claude Code + Codex),按最近活动排序取前 10;头行显示状态灯、`项目名(分支)`、agent、模型与距上次活动时间
96
+ - **三地时钟** — 北京、洛杉矶、伦敦合并为一行,只显示城市与 `HH:MM`
97
+ - **提示词历史** — 运行中、待确认、等输入会话保留最近 5 条,历史最多 1 行、最新最多 2 行;空闲会话只显示最新 1 条,不显示「下一步」
98
+ - **状态灯** — 运行中(星形动画)/ 需要关注(工具调用无结果,大概率在等授权)/ 等输入 / 空闲
99
+ - **「下一步」建议** — 从 AI 最后一条回复的收尾段提取征询 / 待办(纯规则,不走模型);AI 正在用 AskUserQuestion 提问时直接显示问题与选项
100
+ - **点击跳转** — 点会话头行直接切换到它所在的 iTerm2 / tmux 窗格(需启用对应 Agent 的状态栏组件提供映射;Codex 在下一次回答完成后建立映射;iTerm2 首次点击会弹 macOS 自动化授权,属预期)
101
+
102
+ 操作:鼠标拖拽选择文字,松开后自动复制;滚轮 / 方向键 / PgUp/PgDn 滚动,`q` / `Esc` / `Ctrl+C` 退出;`tt sidebar --once` 打印一帧快照即退。数据每 5s 刷新,只读本地会话记录、不写任何产物;加 `--claude` / `--codex` 可只看单个 agent。
103
+
90
104
  ## 安装
91
105
 
92
106
  ```bash
@@ -111,10 +125,11 @@ curl -sSL https://raw.githubusercontent.com/stormzhang/token-tracker/main/instal
111
125
  tt setup # 交互配置向导(终端:上下键选语言 / 主题 / 各组件);非 tty 环境按推荐默认自动配置
112
126
  tt # 过去一年 token 热力图 + 顶部三段概览(= tt daily)
113
127
  tt daily # 同上(tt 无参即进 daily)
114
- tt status # 过去 5h 实时面板
128
+ tt status # 今日消耗、5h/7d 额度与今日会话
115
129
  tt weekly # 周报
116
130
  tt monthly # 月报
117
- tt sessions # 最近 20 条会话明细(tt sessions <n> 改条数、--sort 改排序)
131
+ tt sessions # 最近 20 条会话明细(tt sessions <正整数> 改条数、--sort 改排序)
132
+ tt sidebar # 常驻侧边栏:活跃会话总览 + 提示词历史 + 状态灯 + 点击跳转(--once 一帧快照)
118
133
  tt theme # 查看 / 切换配色主题(show / list / set / preview)
119
134
  tt unsetup # 卸载并恢复安装前的配置
120
135
  tt --version # 查看版本(-v / -V 同义)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "token-tracker"
7
- version = "0.4.7"
7
+ version = "0.4.8"
8
8
  description = "Track token usage across local AI agents (Claude Code, Codex)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -28,14 +28,18 @@ dependencies = [
28
28
  "rich>=13.7",
29
29
  # wizard 补丁挂在 questionary 私有内部(InquirerControl._get_choice_tokens 等),锁大版本防破坏
30
30
  "questionary>=2.0,<3",
31
+ # 仅 tt sidebar live 模式用(cli 延迟 import,日常 tt 启动不加载);与 rich 同门,渲染对象直接复用
32
+ "textual>=1.0",
31
33
  ]
32
34
 
33
35
  [project.optional-dependencies]
34
36
  test = [
35
37
  "pytest>=8.0",
38
+ "pytest-asyncio>=0.24",
36
39
  ]
37
40
  dev = [
38
41
  "pytest>=8.0",
42
+ "pytest-asyncio>=0.24",
39
43
  "ruff>=0.6",
40
44
  "mypy>=1.8",
41
45
  ]
@@ -68,6 +72,8 @@ select = ["E4", "E7", "E9", "F", "W", "I", "UP", "B"]
68
72
  [tool.pytest.ini_options]
69
73
  testpaths = ["tests"]
70
74
  addopts = "-q"
75
+ # sidebar 的 Textual app 用 pilot 异步测试;auto 模式让 async def 用例免装饰器直接跑
76
+ asyncio_mode = "auto"
71
77
 
72
78
  [tool.mypy]
73
79
  python_version = "3.11"
@@ -3,7 +3,7 @@ from datetime import UTC, datetime
3
3
  from pathlib import Path
4
4
 
5
5
  from .types import AgentInfo, UsageEntry
6
- from .util import claude_home, iter_jsonl_dicts, project_from_cwd
6
+ from .util import claude_home, file_may_have_events_since, iter_jsonl_dicts, project_from_cwd
7
7
 
8
8
  CLAUDE_DIRS = [
9
9
  os.path.join(claude_home(), "projects"),
@@ -31,6 +31,8 @@ def load_entries(hours_back: int = 0) -> list[UsageEntry]:
31
31
  if not base.is_dir():
32
32
  continue
33
33
  for jsonl_path in base.rglob("*.jsonl"):
34
+ if not file_may_have_events_since(jsonl_path, cutoff):
35
+ continue
34
36
  fallback_project = _extract_project_from_dir(jsonl_path, base)
35
37
  _parse_jsonl(jsonl_path, fallback_project, entries, seen, cutoff)
36
38
 
@@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta
4
4
  from pathlib import Path
5
5
 
6
6
  from .types import AgentInfo, RateLimits, UsageEntry, normalize_pct
7
- from .util import codex_home, iter_jsonl_dicts, project_from_cwd
7
+ from .util import codex_home, file_may_have_events_since, iter_jsonl_dicts, project_from_cwd
8
8
 
9
9
  CODEX_DIR = codex_home()
10
10
  SESSIONS_DIR = os.path.join(CODEX_DIR, "sessions")
@@ -45,6 +45,8 @@ def load_entries(hours_back: int = 0) -> list[UsageEntry]:
45
45
  return entries
46
46
 
47
47
  for jsonl_path in sessions_path.rglob("*.jsonl"):
48
+ if not file_may_have_events_since(jsonl_path, cutoff):
49
+ continue
48
50
  _parse_jsonl(jsonl_path, models, entries, seen, cutoff)
49
51
 
50
52
  entries.sort(key=lambda e: e.timestamp)
@@ -3,6 +3,7 @@
3
3
  import json
4
4
  import os
5
5
  from collections.abc import Iterator
6
+ from datetime import datetime
6
7
  from pathlib import Path
7
8
 
8
9
 
@@ -45,6 +46,20 @@ def iter_jsonl_dicts(path: Path | str) -> Iterator[dict]:
45
46
  return
46
47
 
47
48
 
49
+ def file_may_have_events_since(path: Path, cutoff: datetime | None) -> bool:
50
+ """文件是否可能包含 cutoff 之后的事件;无窗口时放行,文件消失时跳过。
51
+
52
+ JSONL 只追加写,事件时间不会晚于文件 mtime,因此 mtime 早于 cutoff 的文件可在逐行解析前
53
+ 安全排除。mtime 新只代表“可能有”,仍由解析器按事件时间做最终过滤。
54
+ """
55
+ if cutoff is None:
56
+ return True
57
+ try:
58
+ return path.stat().st_mtime >= cutoff.timestamp()
59
+ except OSError:
60
+ return False
61
+
62
+
48
63
  def project_from_cwd(cwd: str) -> str:
49
64
  """项目名:优先取所属 git 仓库根的目录名(逐级向上找 .git,纯文件系统、不依赖 git 二进制);
50
65
  非仓库 / 仓库根也删了 → 回退去 home 前缀后的最后一段。
@@ -152,7 +152,8 @@ def _load_pricing() -> dict:
152
152
  def _read_cache() -> dict | None:
153
153
  try:
154
154
  with open(CACHE_PATH, encoding="utf-8") as f:
155
- return json.load(f)
155
+ data = json.load(f)
156
+ return data if isinstance(data, dict) else None
156
157
  except (OSError, json.JSONDecodeError):
157
158
  return None
158
159
 
@@ -20,10 +20,12 @@ from .analyzer.aggregator import (
20
20
  from .analyzer.cost import calculate_cost
21
21
  from .hooks import is_setup, needs_update, setup, unsetup, update_hook
22
22
  from .i18n import t
23
+ from .sidebar import registry_update_hint, scan_sessions
23
24
  from .tz import system_tz
24
25
  from .ui import theme, themes
25
26
  from .ui.console import forced_color_console, get_console
26
27
  from .ui.heatmap import render_daily_heatmap
28
+ from .ui.sidebar import render_sidebar
27
29
  from .ui.status import render_sessions_view, render_status
28
30
  from .ui.tables import (
29
31
  render_monthly,
@@ -59,12 +61,15 @@ _REPORT_COMMANDS: dict[str, tuple[Callable, Callable | None, str, str, bool]] =
59
61
 
60
62
 
61
63
  def _parse_limit(args: list[str], default: int) -> int:
62
- for a in args:
63
- try:
64
- return int(a)
65
- except ValueError:
66
- pass
67
- return default
64
+ if not args:
65
+ return default
66
+ try:
67
+ value = int(args[0])
68
+ except ValueError as exc:
69
+ raise ValueError(args[0]) from exc
70
+ if value <= 0:
71
+ raise ValueError(args[0])
72
+ return value
68
73
 
69
74
 
70
75
  def _extract_agent_arg(args: list[str]) -> tuple[list[str], str | None]:
@@ -216,6 +221,22 @@ def _summary_from_sessions(sessions) -> StatusSummary:
216
221
  return sm
217
222
 
218
223
 
224
+ def _cmd_sidebar(agents, args: list[str]) -> None:
225
+ """常驻侧边栏(窄窗格用):活跃会话 + 各自最近提示词。live 模式跑 Textual app
226
+ (备用屏 + 滚动 + 5s 定时刷新,q / Ctrl+C 退出);`--once` 或非 tty 打一帧
227
+ Rich 快照即退(脚本 / `!tt sidebar` / 测试用)。只读 transcript 与心跳文件,
228
+ 不写任何产物;不跟随会话收窄 agent——侧边栏本职是「总览所有会话」,
229
+ 显式 --claude / --codex 才过滤。"""
230
+ agent_ids = {a.id for a in agents}
231
+ if "--once" in args or not sys.stdout.isatty():
232
+ sessions = scan_sessions(agent_ids=agent_ids)
233
+ with forced_color_console():
234
+ get_console().print(render_sidebar(sessions, update_hint=registry_update_hint(sessions)))
235
+ return
236
+ from .ui.sidebar_app import SidebarApp # 延迟 import:textual 仅 live 模式加载
237
+ SidebarApp(agent_ids=agent_ids).run()
238
+
239
+
219
240
  def _current_session_agent() -> str | None:
220
241
  """识别当前所在的 agent 会话(靠**会话内才有**的环境变量):Codex / Claude Code;独立终端返回 None。
221
242
  不能用 CLAUDE_CONFIG_DIR 判断——那是用户级配置变量(可长期 export 在 shell profile 里挪配置目录,
@@ -385,132 +406,150 @@ def cmd_theme(args: list[str]) -> None:
385
406
  get_console().print(f"[dim]{t('theme_usage')}[/dim]")
386
407
 
387
408
 
388
- def main():
389
- # --mock:本地开发演示,加载 mock/ 假数据再走正常报表流程(mock/ 在 .gitignore)
390
- if "--mock" in sys.argv:
391
- sys.argv = [a for a in sys.argv if a != "--mock"]
392
- _load_local_mock()
409
+ def _apply_theme_override(name: str | None) -> None:
410
+ if name is None:
411
+ return
412
+ if name not in themes.THEMES:
413
+ get_console().print(f"[red]{t('theme_unknown', name=name)}[/red]")
414
+ get_console().print(f"[dim]{t('theme_options', names=', '.join(themes.THEME_NAMES))}[/dim]")
415
+ sys.exit(1)
416
+ theme.set_active_theme(name)
393
417
 
394
- args = sys.argv[1:]
395
- # --claude / --codex:多 agent 环境按需只看一个 agent 的报表(issue #19),互斥;不给则走默认
396
- args, filter_agent = _extract_agent_arg(args)
397
- # --theme NAME:临时覆盖主题(仅本次进程、不落配置/不重烘焙状态栏),对所有报表 + status 生效
398
- args, theme_override = _extract_theme_arg(args)
399
- if theme_override is not None:
400
- if theme_override not in themes.THEMES:
401
- get_console().print(f"[red]{t('theme_unknown', name=theme_override)}[/red]")
402
- get_console().print(f"[dim]{t('theme_options', names=', '.join(themes.THEME_NAMES))}[/dim]")
403
- sys.exit(1)
404
- theme.set_active_theme(theme_override)
405
- command = args[0] if args else "daily"
406
418
 
407
- # 版本查询不该触发任何文件读写,放在 auto-update 之前短路返回
419
+ def _handle_non_data_command(command: str, args: list[str]) -> bool:
420
+ """处理不需要 Agent 数据的命令;已处理返回 True。"""
408
421
  if command in ("--version", "-v", "-V"):
409
422
  print(f"token-tracker {_get_version()}")
410
423
  print("by stormzhang · https://github.com/stormzhang/token-tracker")
411
- return
412
-
424
+ return True
413
425
  if command == "theme":
414
- cmd_theme(args[1:])
415
- return
416
-
417
- # 已配置过的情况下,任意命令都顺带同步状态栏脚本(setup/unsetup 自行处理)
418
- # 避免升级 pip 包后忘了 tt setup,导致 ~/.claude/tt-statusline.py 停在旧版本
419
- if command not in ("setup", "unsetup") and is_setup() and needs_update():
420
- update_hook()
421
-
422
- # 升级感知:新版若新增了值得重配的选项(SETUP_VERSION bump),老用户跑任意命令时
423
- # 自动走一遍 setup——_run_setup_flow 内部分流:真终端弹 wizard、会话内 / 非 tty 静默
424
- # _auto_setup 用默认值全装(语言跟随系统 / mocha / 组件全开)。两者最终都 save_setup_version(),
425
- # 下次启动 setup_version 已是最新、不再触发。
426
- if (
427
- command not in ("setup", "unsetup")
428
- and is_setup()
429
- and config.setup_version() < config.SETUP_VERSION
430
- ):
431
- _run_setup_flow()
432
-
426
+ cmd_theme(args)
427
+ return True
433
428
  if command == "setup":
434
429
  _run_setup_flow()
435
- return
430
+ return True
436
431
  if command == "unsetup":
437
432
  unsetup()
438
- return
433
+ return True
434
+ return False
435
+
439
436
 
440
- # 数据命令只看「配没配过」:没配过 走 setup 流程(装没装 agent 的检测都在那),
441
- # 引导后仍未配置(零 agent / 用户取消)→ 退出。配过则直接往下拿 agents 跑。
437
+ def _ensure_data_ready() -> None:
438
+ """数据命令运行前同步脚本、处理升级引导,并确保至少完成过一次 setup。"""
439
+ configured = is_setup()
440
+ if configured and needs_update():
441
+ update_hook()
442
+ if configured and config.setup_version() < config.SETUP_VERSION:
443
+ _run_setup_flow()
442
444
  if not is_setup():
443
445
  _run_setup_flow()
444
446
  if not is_setup():
445
447
  sys.exit(1)
446
448
 
447
- agents = detect_agents()
448
- if filter_agent is not None:
449
- # 显式指定 agent:收窄 agents 列表,所有报表命令自动跟随;未装 / 未检测到 → 报错退出
450
- matched = [a for a in agents if a.id == filter_agent]
451
- if not matched:
452
- flag = "--claude" if filter_agent == "claude-code" else "--codex"
453
- get_console().print(f"[red]{t('agent_not_detected', flag=flag)}[/red]")
454
- sys.exit(1)
455
- agents = matched
456
- agent_ids = {a.id for a in agents}
457
-
458
- if command in ("status", "dashboard"):
459
- data = _build_status_data(agents)
460
- if not data:
461
- get_console().print(f"[yellow]{t('no_token_data')}[/yellow]")
462
- return
463
- render_status(**data)
464
- return
465
-
466
- rest_args, sort_key, sort_desc = _parse_sort_args(args[1:])
467
449
 
450
+ def _select_agents(filter_agent: str | None):
451
+ agents = detect_agents()
452
+ if filter_agent is None:
453
+ return agents
454
+ matched = [agent for agent in agents if agent.id == filter_agent]
455
+ if matched:
456
+ return matched
457
+ flag = "--claude" if filter_agent == "claude-code" else "--codex"
458
+ get_console().print(f"[red]{t('agent_not_detected', flag=flag)}[/red]")
459
+ sys.exit(1)
460
+
461
+
462
+ def _report_agents(agents, command: str, filter_agent: str | None):
463
+ """daily/weekly 在 Agent 会话内自动收窄;显式 filter 优先。"""
464
+ if filter_agent is not None or command not in ("daily", "weekly"):
465
+ return agents
466
+ session_agent = _current_session_agent()
467
+ if not session_agent or session_agent not in {agent.id for agent in agents}:
468
+ return agents
469
+ return [agent for agent in agents if agent.id == session_agent]
470
+
471
+
472
+ def _run_report_command(command: str, args: list[str], agents, filter_agent: str | None) -> None:
473
+ rest_args, sort_key, sort_desc = _parse_sort_args(args)
468
474
  if command not in _REPORT_COMMANDS:
469
475
  get_console().print(f"[red]{t('unknown_cmd', cmd=command)}[/red]")
470
476
  get_console().print(f"[dim]{t('available_cmds')}[/dim]")
471
477
  sys.exit(1)
472
478
 
473
- # daily / weekly 跟随当前会话:CC 会话只看 CC、Codex 会话只看 Codex;
474
- # 独立终端(识别不到会话)保持合并所有 agent。显式 --claude / --codex 已在上面收窄 agents、优先级最高。
475
- report_agents = agents
476
- if filter_agent is None and command in ("daily", "weekly"):
477
- session_agent = _current_session_agent()
478
- if session_agent and session_agent in agent_ids:
479
- report_agents = [a for a in agents if a.id == session_agent]
480
- agent_names = [a.name for a in report_agents]
481
-
479
+ selected = _report_agents(agents, command, filter_agent)
480
+ agent_names = [agent.name for agent in selected]
482
481
  agg_fn, render_fn, time_attr, no_sort_attr, default_reverse = _REPORT_COMMANDS[command]
483
- loaded = _load_per_agent(report_agents)
482
+ loaded = _load_per_agent(selected)
484
483
  stats = _aggregate_per_agent(loaded, agg_fn)
485
484
  default_attr = time_attr if sort_key == "time" else no_sort_attr
486
485
 
487
486
  if command == "sessions":
488
- # sessions 看「最近的会话」:先过滤掉活跃 <5min 的碎片会话(与 tt status 同口径,
489
- # types.SessionStats.active_minutes),再按时间取最近 N 条(否则史上高 cost 会话
490
- # 恒久霸榜、新会话和低成本 agent 永远进不了榜),这 N 条再按 cost(或 --sort)展示
491
- kept = [s for s in stats if s.active_minutes >= 5]
492
- kept.sort(key=lambda s: s.start_time, reverse=True)
493
- shown = kept[:_parse_limit(rest_args, default=20)]
494
- _apply_sort(shown, sort_key, sort_desc, default_attr, default_reverse)
495
- render_sessions_view(_summary_from_sessions(shown), shown, agent_names)
487
+ _render_session_report(stats, rest_args, sort_key, sort_desc,
488
+ default_attr, default_reverse, agent_names)
496
489
  return
497
490
 
498
491
  assert render_fn is not None # sessions(render_fn=None)已在上面 return,其余命令都有渲染函数
499
492
  _apply_sort(stats, sort_key, sort_desc, default_attr, default_reverse)
500
493
  if command == "daily":
501
- # daily 顶部三卡片:Last 12 months + This Month + This Week,跟 weekly/monthly 同款样式、
502
- # 复用 _render_month_summary / _render_week_summary。weekly/monthly 聚合复用同一份 entries。
503
494
  render_fn(stats, agents=agent_names,
504
495
  weekly=_aggregate_per_agent(loaded, aggregate_weekly),
505
496
  monthly=_aggregate_per_agent(loaded, aggregate_monthly))
506
497
  elif command == "weekly":
507
498
  render_weekly(stats, agents=agent_names, daily=_aggregate_per_agent(loaded, aggregate_daily))
508
- elif command == "monthly":
499
+ else: # monthly
509
500
  render_monthly(stats, agents=agent_names,
510
501
  daily=_aggregate_per_agent(loaded, aggregate_daily),
511
502
  weekly=_aggregate_per_agent(loaded, aggregate_weekly))
512
- else:
513
- render_fn(stats, agents=agent_names)
503
+
504
+
505
+ def _render_session_report(stats, rest_args: list[str], sort_key: str | None,
506
+ sort_desc: bool | None, default_attr: str,
507
+ default_reverse: bool, agent_names: list[str]) -> None:
508
+ # 先按时间取最近 N 条,再按用户指定字段展示;避免历史高 cost 会话长期霸榜。
509
+ kept = [session for session in stats if session.active_minutes >= 5]
510
+ kept.sort(key=lambda session: session.start_time, reverse=True)
511
+ try:
512
+ limit = _parse_limit(rest_args, default=20)
513
+ except ValueError as exc:
514
+ get_console().print(f"[red]{t('sessions_limit_invalid', value=exc.args[0])}[/red]")
515
+ sys.exit(1)
516
+ shown = kept[:limit]
517
+ _apply_sort(shown, sort_key, sort_desc, default_attr, default_reverse)
518
+ render_sessions_view(_summary_from_sessions(shown), shown, agent_names)
519
+
520
+
521
+ def main():
522
+ # --mock:本地开发演示,加载 mock/ 假数据再走正常报表流程(mock/ 在 .gitignore)
523
+ if "--mock" in sys.argv:
524
+ sys.argv = [a for a in sys.argv if a != "--mock"]
525
+ _load_local_mock()
526
+
527
+ args = sys.argv[1:]
528
+ # --claude / --codex:多 agent 环境按需只看一个 agent 的报表(issue #19),互斥;不给则走默认
529
+ args, filter_agent = _extract_agent_arg(args)
530
+ # --theme NAME:临时覆盖主题(仅本次进程、不落配置/不重烘焙状态栏),对所有报表 + status 生效
531
+ args, theme_override = _extract_theme_arg(args)
532
+ _apply_theme_override(theme_override)
533
+ command = args[0] if args else "daily"
534
+
535
+ if _handle_non_data_command(command, args[1:]):
536
+ return
537
+
538
+ _ensure_data_ready()
539
+ agents = _select_agents(filter_agent)
540
+
541
+ if command in ("status", "dashboard"):
542
+ data = _build_status_data(agents)
543
+ if not data:
544
+ get_console().print(f"[yellow]{t('no_token_data')}[/yellow]")
545
+ return
546
+ render_status(**data)
547
+ return
548
+
549
+ if command == "sidebar":
550
+ _cmd_sidebar(agents, args[1:])
551
+ return
552
+ _run_report_command(command, args[1:], agents, filter_agent)
514
553
 
515
554
 
516
555
  if __name__ == "__main__":
@@ -18,8 +18,11 @@ from .ui import themes
18
18
  CONFIG_DIR = os.path.expanduser("~/.config/token-tracker")
19
19
  CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
20
20
  # CC statusline 缓存的单一权威路径(statusline 脚本写、tt status / adapters.rate_limits 读)。
21
- # 注意 templates/claude_statusline.py 因脚本独立运行硬编码了同一路径——改这里必须同步改模板。
21
+ # 注意两个 statusline 模板因脚本独立运行硬编码了对应缓存路径——改这里必须同步改模板。
22
22
  STATUS_FILE = os.path.join(CONFIG_DIR, "tt-status.json")
23
+ # Codex Stop hook 采集的会话→终端窗格映射。与 CC 心跳/status 缓存分文件,避免两个
24
+ # Agent 并发 read-modify-write 同一 JSON 时互相覆盖;sidebar 读取时再合并。
25
+ TERMINAL_MAP_FILE = os.path.join(CONFIG_DIR, "tt-terminal-map.json")
23
26
  SCHEMA_VERSION = 1
24
27
 
25
28
  # 引导版本:每次新增"值得让老用户重新走一遍 setup"的配置项时手动 +1(只能整数、一次 +1)。
@@ -38,8 +38,9 @@ CODEX_DIR = _CODEX
38
38
  CODEX_CONFIG = os.path.join(CODEX_DIR, "config.toml") # 改 Codex 配置,留 agent 目录
39
39
  CODEX_STATUSLINE_HOOK_PATH = os.path.join(_TT, "codex-statusline.py")
40
40
  STATUS_FILE = config.STATUS_FILE # CC statusline 缓存(单一权威定义在 config)
41
- HOOK_VERSION = "1.8"
42
- STATUSLINE_HOOK_VERSION = "1.1"
41
+ TERMINAL_MAP_FILE = config.TERMINAL_MAP_FILE # Codex Stop hook 采集的终端定位映射
42
+ HOOK_VERSION = "2.1" # 2.0: 采集 _terminal_map(sidebar 点击跳转);2.1: 共享状态无条件随帧携带、防异常帧清表
43
+ STATUSLINE_HOOK_VERSION = "1.2" # 1.2: 采集 Codex 会话终端定位,供 tt sidebar 点击跳转
43
44
 
44
45
  CC_BACKUP_PATH = os.path.join(_TT, "cc-backup.json")
45
46
  CODEX_BACKUP_LEGACY = os.path.join(_TT, "codex-backup.json") # 老用户残留,unsetup 时还能恢复
@@ -162,6 +163,8 @@ def _uninstall_codex_statusline(content: str) -> str:
162
163
  """删 Codex statusline 脚本 + 从 content 移除 tt 追加的 Stop hook 段(不动用户其它)。"""
163
164
  if os.path.exists(CODEX_STATUSLINE_HOOK_PATH):
164
165
  os.remove(CODEX_STATUSLINE_HOOK_PATH)
166
+ if os.path.exists(TERMINAL_MAP_FILE):
167
+ os.remove(TERMINAL_MAP_FILE)
165
168
  return _CODEX_STATUSLINE_REGEX.sub("\n", content)
166
169
 
167
170
 
@@ -10,7 +10,24 @@ _STRINGS = {
10
10
  "unknown_cmd": "未知命令: {cmd}",
11
11
  "agent_filter_conflict": "--claude 与 --codex 互斥,只能指定其中一个",
12
12
  "agent_not_detected": "未检测到 {flag} 的使用数据,请先在该 agent 中使用后再查询",
13
- "available_cmds": "可用命令: status, daily, weekly, monthly, sessions, theme, setup, unsetup, --version",
13
+ "sessions_limit_invalid": "会话条数必须是正整数,收到: {value}",
14
+ "available_cmds": "可用命令: status, daily, weekly, monthly, sessions, sidebar, theme, setup, unsetup, --version",
15
+ # --- sidebar(cli.py / ui/sidebar.py)---
16
+ "sidebar_empty": "窗口期内没有活跃会话",
17
+ "sidebar_waiting_prompt": "等待当前会话的第一条提示词…",
18
+ "sidebar_state_running": "运行中",
19
+ "sidebar_state_attention": "待确认",
20
+ "sidebar_state_waiting": "等输入",
21
+ "sidebar_state_idle": "空闲",
22
+ "sidebar_jump_no_target": "该会话暂无终端定位(其状态栏渲染过一帧后才有)",
23
+ "sidebar_jump_failed": "跳转失败: {err}",
24
+ "sidebar_next": "下一步",
25
+ "sidebar_active_count": "最近活跃会话 {n} 条",
26
+ "sidebar_tz_bj": "北京",
27
+ "sidebar_tz_la": "洛杉矶",
28
+ "sidebar_tz_ldn": "伦敦",
29
+ "sidebar_jump_gone": "目标终端窗格不存在(可能已关闭)",
30
+ "sidebar_update_hint": "Claude Code 版本较旧,无法识别已关闭的会话,建议升级",
14
31
  # --- status.py(会话表列名与 tips)---
15
32
  "recent_sessions": "最近会话",
16
33
  "sessions_tips": "Tips: tt sessions <N> 调数量 · --sort cost|tokens|time|messages · --asc/--desc 改排序",
@@ -41,7 +58,6 @@ _STRINGS = {
41
58
  "theme_usage": "用法: tt theme [show | list | set <主题名> | preview <主题名>]",
42
59
  # --- wizard (wizard.py) ---
43
60
  "wizard_pick_theme": "选择配色主题",
44
- "wizard_theme_prompt": "选择主题",
45
61
  "wizard_q_cc_statusline": "接管 Claude Code 状态栏",
46
62
  "wizard_q_codex_statusline": "启用 Codex 伪 statusline",
47
63
  "theme_recommended": "(推荐)",
@@ -86,7 +102,24 @@ _STRINGS = {
86
102
  "unknown_cmd": "Unknown command: {cmd}",
87
103
  "agent_filter_conflict": "--claude and --codex are mutually exclusive; please pick one",
88
104
  "agent_not_detected": "No usage data for {flag}; use it in that agent first, then query again",
89
- "available_cmds": "Available commands: status, daily, weekly, monthly, sessions, theme, setup, unsetup, --version",
105
+ "sessions_limit_invalid": "Session count must be a positive integer, got: {value}",
106
+ "available_cmds": "Available commands: status, daily, weekly, monthly, sessions, sidebar, theme, setup, unsetup, --version",
107
+ # --- sidebar(cli.py / ui/sidebar.py)---
108
+ "sidebar_empty": "No active sessions in the window",
109
+ "sidebar_waiting_prompt": "Waiting for the first prompt in this session…",
110
+ "sidebar_state_running": "running",
111
+ "sidebar_state_attention": "needs you",
112
+ "sidebar_state_waiting": "awaiting input",
113
+ "sidebar_state_idle": "idle",
114
+ "sidebar_jump_no_target": "No terminal mapping for this session yet (appears after its statusline renders a frame)",
115
+ "sidebar_jump_failed": "Jump failed: {err}",
116
+ "sidebar_next": "Next",
117
+ "sidebar_active_count": "{n} active sessions",
118
+ "sidebar_tz_bj": "Beijing",
119
+ "sidebar_tz_la": "LA",
120
+ "sidebar_tz_ldn": "London",
121
+ "sidebar_jump_gone": "Target terminal pane no longer exists (probably closed)",
122
+ "sidebar_update_hint": "Claude Code is too old to detect closed sessions; consider updating",
90
123
  # --- status.py(会话表列名与 tips)---
91
124
  "recent_sessions": "Recent Sessions",
92
125
  "sessions_tips": "Tips: tt sessions <N> for count · --sort cost|tokens|time|messages · --asc/--desc to sort",
@@ -117,7 +150,6 @@ _STRINGS = {
117
150
  "theme_usage": "Usage: tt theme [show | list | set <name> | preview <name>]",
118
151
  # --- wizard (wizard.py) ---
119
152
  "wizard_pick_theme": "Pick a theme",
120
- "wizard_theme_prompt": "Pick a theme",
121
153
  "wizard_q_cc_statusline": "Take over Claude Code status line",
122
154
  "wizard_q_codex_statusline": "Enable Codex faux statusline",
123
155
  "theme_recommended": "(recommended)",