token-tracker 0.3.4__tar.gz → 0.3.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.
- {token_tracker-0.3.4 → token_tracker-0.3.6}/PKG-INFO +1 -1
- {token_tracker-0.3.4 → token_tracker-0.3.6}/README.md +42 -3
- {token_tracker-0.3.4 → token_tracker-0.3.6}/pyproject.toml +1 -1
- {token_tracker-0.3.4 → token_tracker-0.3.6}/src/adapters/codex.py +4 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/src/cli.py +117 -17
- {token_tracker-0.3.4 → token_tracker-0.3.6}/src/hooks.py +74 -48
- token_tracker-0.3.6/src/i18n.py +196 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/src/ui/tables.py +93 -91
- {token_tracker-0.3.4 → token_tracker-0.3.6}/token_tracker.egg-info/PKG-INFO +1 -1
- {token_tracker-0.3.4 → token_tracker-0.3.6}/token_tracker.egg-info/SOURCES.txt +1 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/setup.cfg +0 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/src/__init__.py +0 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/src/adapters/__init__.py +0 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/src/adapters/claude.py +0 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/src/adapters/rate_limits.py +0 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/src/adapters/registry.py +0 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/src/adapters/types.py +0 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/src/analyzer/__init__.py +0 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/src/analyzer/aggregator.py +0 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/src/analyzer/blocks.py +0 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/src/analyzer/cost.py +0 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/src/ui/__init__.py +0 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/token_tracker.egg-info/dependency_links.txt +0 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/token_tracker.egg-info/entry_points.txt +0 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/token_tracker.egg-info/requires.txt +0 -0
- {token_tracker-0.3.4 → token_tracker-0.3.6}/token_tracker.egg-info/top_level.txt +0 -0
|
@@ -12,11 +12,28 @@
|
|
|
12
12
|
|
|
13
13
|
自动为 Claude Code 和 Codex 配置状态栏,`tt setup` 一键配置,脚本更新时自动升级。
|
|
14
14
|
|
|
15
|
-
**Claude Code
|
|
15
|
+
**Claude Code**:基于官方自定义 StatusLine 接口,数据完全来自本地 Claude,准确无任何推测
|
|
16
16
|
|
|
17
17
|

|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
状态栏共三行,从左到右:
|
|
20
|
+
|
|
21
|
+
| 行 | 字段 | 说明 |
|
|
22
|
+
|----|------|------|
|
|
23
|
+
| 1 | `项目名(分支)` | 当前项目目录 + Git 分支,未提交的修改会标 `*` |
|
|
24
|
+
| 1 | `5h: ██░ 31% (1h19m)` | 5 小时滑动窗口配额用量,括号内为重置倒计时 |
|
|
25
|
+
| 1 | `7d: ██░ 11% (5d8h)` | 7 天滑动窗口配额用量 |
|
|
26
|
+
| 1 | `1.0M Context: ██░ 20%` | 上下文窗口总大小及已用占比 |
|
|
27
|
+
| 2 | `Tokens: in 155k, out 128k` | 本次会话累计输入/输出 Token |
|
|
28
|
+
| 2 | `(本轮: in 1, out 15)` | 当前对话轮次的 Token 用量 |
|
|
29
|
+
| 2 | `Cached: 204k` | 当前轮次命中的 Prompt Cache Token 数 |
|
|
30
|
+
| 2 | `Cost: $35.51` | 本次会话等效成本(按官方定价计算) |
|
|
31
|
+
| 3 | `Model: Opus 4.6/high/nofast` | 模型名 / thinking 级别 / 是否 fast 模式 |
|
|
32
|
+
| 3 | `Duration: 1h33m` | 当前会话已持续时间 |
|
|
33
|
+
|
|
34
|
+
> 终端宽度不足时会自动降级:先隐藏重置倒计时,再将进度条简化为百分比数字。
|
|
35
|
+
|
|
36
|
+
**Codex**:官方暂不支持自定义 StatusLine,使用官方默认样式,展示项目名、5h/7d 配额、上下文剩余、模型名
|
|
20
37
|
|
|
21
38
|

|
|
22
39
|
|
|
@@ -43,7 +60,7 @@
|
|
|
43
60
|
## 安装
|
|
44
61
|
|
|
45
62
|
```bash
|
|
46
|
-
curl -sSL https://raw.githubusercontent.com/stormzhang/token-tracker/
|
|
63
|
+
curl -sSL https://raw.githubusercontent.com/stormzhang/token-tracker/main/install.sh | bash
|
|
47
64
|
```
|
|
48
65
|
|
|
49
66
|
或者通过 pip:
|
|
@@ -67,6 +84,28 @@ tt sessions # 最近 20 条会话明细数据
|
|
|
67
84
|
tt unsetup # 卸载并恢复安装前的配置
|
|
68
85
|
```
|
|
69
86
|
|
|
87
|
+
### 报告排序
|
|
88
|
+
|
|
89
|
+
所有报告命令支持 `--sort` 和 `--asc/--desc` 参数:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
tt daily --sort cost --desc # 按成本降序
|
|
93
|
+
tt sessions --sort tokens --asc # 按 token 升序
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
可选排序字段:`tokens` / `cost` / `messages` / `time` / `input` / `output`
|
|
97
|
+
|
|
98
|
+
### Dashboard 快捷键
|
|
99
|
+
|
|
100
|
+
| 按键 | 功能 |
|
|
101
|
+
|------|------|
|
|
102
|
+
| `←` `→` | 切换 Agent |
|
|
103
|
+
| `↑` `↓` | 滚动内容 |
|
|
104
|
+
| `s` | 切换排序字段(时间 → Token → 等效成本 → 消息数) |
|
|
105
|
+
| `r` | 反转排序方向 |
|
|
106
|
+
| `+` / `-` | 调整会话显示条数(±10,最少 10 条) |
|
|
107
|
+
| `q` | 退出 |
|
|
108
|
+
|
|
70
109
|
## 环境要求
|
|
71
110
|
|
|
72
111
|
- Python 3.11+
|
|
@@ -82,6 +82,8 @@ def _extract_rate_limits(path: Path, models: dict[str, str]) -> RateLimits | Non
|
|
|
82
82
|
data = json.loads(line)
|
|
83
83
|
except json.JSONDecodeError:
|
|
84
84
|
continue
|
|
85
|
+
if not isinstance(data, dict):
|
|
86
|
+
continue
|
|
85
87
|
if data.get("type") == "session_meta":
|
|
86
88
|
session_id = data.get("payload", {}).get("id", "")
|
|
87
89
|
if data.get("type") != "event_msg":
|
|
@@ -162,6 +164,8 @@ def _parse_jsonl(
|
|
|
162
164
|
data = json.loads(line)
|
|
163
165
|
except json.JSONDecodeError:
|
|
164
166
|
continue
|
|
167
|
+
if not isinstance(data, dict):
|
|
168
|
+
continue
|
|
165
169
|
|
|
166
170
|
row_type = data.get("type")
|
|
167
171
|
|
|
@@ -6,6 +6,7 @@ from .adapters.registry import detect_agents
|
|
|
6
6
|
from .analyzer.aggregator import aggregate_daily, aggregate_monthly, aggregate_sessions, aggregate_weekly
|
|
7
7
|
from .analyzer.blocks import analyze_blocks, calculate_p90
|
|
8
8
|
from .hooks import is_setup, needs_update, setup, unsetup, update_hook
|
|
9
|
+
from .i18n import t
|
|
9
10
|
from .ui.tables import (
|
|
10
11
|
AGENT_LABEL, console, render_daily, render_dashboard,
|
|
11
12
|
render_monthly, render_sessions, render_tab_bar, render_weekly,
|
|
@@ -15,6 +16,55 @@ AGENT_ALIASES = {"claude": "claude-code", "codex": "codex"}
|
|
|
15
16
|
AGENT_LOADERS = {"claude-code": claude, "codex": codex}
|
|
16
17
|
RATE_LIMIT_LOADERS = {"claude-code": load_claude_rate_limits, "codex": codex.load_rate_limits}
|
|
17
18
|
|
|
19
|
+
SORT_KEYS = {
|
|
20
|
+
"tokens": ("total_tokens", True),
|
|
21
|
+
"cost": ("cost_usd", True),
|
|
22
|
+
"messages": ("message_count", True),
|
|
23
|
+
"sessions": ("session_count", True),
|
|
24
|
+
"time": None, # handled per-command
|
|
25
|
+
"input": ("input_tokens", True),
|
|
26
|
+
"output": ("output_tokens", True),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_sort_args(args: list[str]) -> tuple[list[str], str | None, bool]:
|
|
31
|
+
"""Extract --sort KEY and --asc from args, return (remaining, sort_key, descending)."""
|
|
32
|
+
remaining = []
|
|
33
|
+
sort_key = None
|
|
34
|
+
descending = True
|
|
35
|
+
i = 0
|
|
36
|
+
while i < len(args):
|
|
37
|
+
if args[i] == "--sort" and i + 1 < len(args):
|
|
38
|
+
sort_key = args[i + 1].lower()
|
|
39
|
+
i += 2
|
|
40
|
+
elif args[i] == "--asc":
|
|
41
|
+
descending = False
|
|
42
|
+
i += 1
|
|
43
|
+
elif args[i] == "--desc":
|
|
44
|
+
descending = True
|
|
45
|
+
i += 1
|
|
46
|
+
else:
|
|
47
|
+
remaining.append(args[i])
|
|
48
|
+
i += 1
|
|
49
|
+
return remaining, sort_key, descending
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _apply_sort(stats, sort_key: str | None, descending: bool, default_attr: str, default_reverse: bool):
|
|
53
|
+
if sort_key is None:
|
|
54
|
+
stats.sort(key=lambda s: getattr(s, default_attr), reverse=default_reverse)
|
|
55
|
+
return
|
|
56
|
+
if sort_key not in SORT_KEYS:
|
|
57
|
+
valid = ", ".join(SORT_KEYS.keys())
|
|
58
|
+
console.print(f"[yellow]{t('unknown_sort_field', key=sort_key, valid=valid)}[/yellow]")
|
|
59
|
+
stats.sort(key=lambda s: getattr(s, default_attr), reverse=default_reverse)
|
|
60
|
+
return
|
|
61
|
+
mapping = SORT_KEYS[sort_key]
|
|
62
|
+
if mapping is None:
|
|
63
|
+
stats.sort(key=lambda s: getattr(s, default_attr), reverse=descending)
|
|
64
|
+
else:
|
|
65
|
+
attr, _ = mapping
|
|
66
|
+
stats.sort(key=lambda s: getattr(s, attr), reverse=descending)
|
|
67
|
+
|
|
18
68
|
|
|
19
69
|
def _load_entries(agent_id: str, hours_back: int = 0):
|
|
20
70
|
loader = AGENT_LOADERS.get(agent_id)
|
|
@@ -35,7 +85,7 @@ def _show_agent_dashboard(agent_id: str):
|
|
|
35
85
|
agent_name = AGENT_LABEL.get(agent_id, agent_id)
|
|
36
86
|
data = _build_agent_data(agent_id, agent_name)
|
|
37
87
|
if not data:
|
|
38
|
-
console.print(f"[yellow]
|
|
88
|
+
console.print(f"[yellow]{t('no_token_data')}[/yellow]")
|
|
39
89
|
return
|
|
40
90
|
render_dashboard(**data)
|
|
41
91
|
|
|
@@ -91,6 +141,15 @@ def _fit_screen(text: str, height: int, scroll_offset: int) -> tuple[str, int]:
|
|
|
91
141
|
return "\n".join(visible), max_scroll
|
|
92
142
|
|
|
93
143
|
|
|
144
|
+
def _dashboard_sort_cycle():
|
|
145
|
+
return [
|
|
146
|
+
("time", "start_time", t("sort_time")),
|
|
147
|
+
("tokens", "total_tokens", t("sort_token")),
|
|
148
|
+
("cost", "cost_usd", t("sort_cost")),
|
|
149
|
+
("messages", "message_count", t("sort_messages")),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
|
|
94
153
|
def _show_interactive_dashboard(agents):
|
|
95
154
|
import shutil
|
|
96
155
|
from io import StringIO
|
|
@@ -100,16 +159,20 @@ def _show_interactive_dashboard(agents):
|
|
|
100
159
|
agent_names = [a.name for a in agents]
|
|
101
160
|
current = _initial_agent_index(agents)
|
|
102
161
|
scroll_offset = 0
|
|
162
|
+
sort_idx = 0
|
|
163
|
+
sort_desc = True
|
|
164
|
+
session_limit = 30
|
|
103
165
|
orig = _tables.console
|
|
104
166
|
|
|
105
167
|
sys.stdout.write("\033[?1049h\033[?7l\033[2J\033[3J\033[H\033[?25l")
|
|
106
168
|
cache = {}
|
|
169
|
+
sort_cycle = _dashboard_sort_cycle()
|
|
107
170
|
|
|
108
171
|
try:
|
|
109
172
|
while True:
|
|
110
173
|
agent = agents[current]
|
|
111
174
|
if agent.id not in cache:
|
|
112
|
-
sys.stdout.write("\033[2J\033[3J\033[H\033[2m
|
|
175
|
+
sys.stdout.write(f"\033[2J\033[3J\033[H\033[2m{t('loading')}\033[0m")
|
|
113
176
|
sys.stdout.flush()
|
|
114
177
|
cache[agent.id] = _build_agent_data(agent.id, agent.name)
|
|
115
178
|
|
|
@@ -117,16 +180,30 @@ def _show_interactive_dashboard(agents):
|
|
|
117
180
|
width = size.columns
|
|
118
181
|
height = size.lines
|
|
119
182
|
|
|
183
|
+
data = cache[agent.id]
|
|
184
|
+
if data:
|
|
185
|
+
_, sort_attr, sort_label = sort_cycle[sort_idx]
|
|
186
|
+
sorted_sessions = sorted(
|
|
187
|
+
data["sessions"],
|
|
188
|
+
key=lambda s: getattr(s, sort_attr),
|
|
189
|
+
reverse=sort_desc,
|
|
190
|
+
)
|
|
191
|
+
arrow = "↓" if sort_desc else "↑"
|
|
192
|
+
session_title = t("session_title", limit=session_limit, label=sort_label, arrow=arrow)
|
|
193
|
+
else:
|
|
194
|
+
sorted_sessions = []
|
|
195
|
+
session_title = None
|
|
196
|
+
|
|
120
197
|
buf = StringIO()
|
|
121
198
|
_tables.console = RichConsole(
|
|
122
199
|
file=buf, width=width, force_terminal=True,
|
|
123
200
|
)
|
|
124
201
|
render_tab_bar(agent_names, current)
|
|
125
|
-
data = cache[agent.id]
|
|
126
202
|
if data:
|
|
127
|
-
|
|
203
|
+
render_data = {**data, "sessions": sorted_sessions}
|
|
204
|
+
render_dashboard(**render_data, session_limit=session_limit, top_margin=False, session_title=session_title)
|
|
128
205
|
else:
|
|
129
|
-
_tables.console.print(f"[yellow]
|
|
206
|
+
_tables.console.print(f"[yellow]{t('no_data')}[/yellow]")
|
|
130
207
|
_tables.console = orig
|
|
131
208
|
|
|
132
209
|
screen, max_scroll = _fit_screen(buf.getvalue(), height, scroll_offset)
|
|
@@ -148,6 +225,15 @@ def _show_interactive_dashboard(agents):
|
|
|
148
225
|
scroll_offset = max(0, scroll_offset - max(1, height - 3))
|
|
149
226
|
elif key == "page_down":
|
|
150
227
|
scroll_offset = min(max_scroll, scroll_offset + max(1, height - 3))
|
|
228
|
+
elif key == "sort":
|
|
229
|
+
sort_idx = (sort_idx + 1) % len(sort_cycle)
|
|
230
|
+
scroll_offset = 0
|
|
231
|
+
elif key == "reverse":
|
|
232
|
+
sort_desc = not sort_desc
|
|
233
|
+
elif key == "more":
|
|
234
|
+
session_limit += 10
|
|
235
|
+
elif key == "less":
|
|
236
|
+
session_limit = max(10, session_limit - 10)
|
|
151
237
|
elif key == "quit":
|
|
152
238
|
break
|
|
153
239
|
finally:
|
|
@@ -197,6 +283,14 @@ def _read_key_unix():
|
|
|
197
283
|
return "page_up"
|
|
198
284
|
if ch == b"f":
|
|
199
285
|
return "page_down"
|
|
286
|
+
if ch == b"s":
|
|
287
|
+
return "sort"
|
|
288
|
+
if ch == b"r":
|
|
289
|
+
return "reverse"
|
|
290
|
+
if ch in (b"+", b"="):
|
|
291
|
+
return "more"
|
|
292
|
+
if ch in (b"-", b"_"):
|
|
293
|
+
return "less"
|
|
200
294
|
if ch in (b"q", b"Q", b"\x03"):
|
|
201
295
|
return "quit"
|
|
202
296
|
return "other"
|
|
@@ -263,13 +357,13 @@ def main():
|
|
|
263
357
|
|
|
264
358
|
agents = detect_agents()
|
|
265
359
|
if not agents:
|
|
266
|
-
console.print("[red]
|
|
360
|
+
console.print(f"[red]{t('no_agent')}[/red]")
|
|
267
361
|
sys.exit(1)
|
|
268
362
|
|
|
269
363
|
agent_ids = {a.id for a in agents}
|
|
270
364
|
|
|
271
365
|
if command != "dashboard":
|
|
272
|
-
console.print(f"[dim]
|
|
366
|
+
console.print(f"[dim]{t('detected', agents=', '.join(a.name + ' ✓' for a in agents))}[/dim]")
|
|
273
367
|
|
|
274
368
|
if not is_setup():
|
|
275
369
|
setup(auto=True)
|
|
@@ -280,7 +374,7 @@ def main():
|
|
|
280
374
|
if command in AGENT_ALIASES:
|
|
281
375
|
agent_id = AGENT_ALIASES[command]
|
|
282
376
|
if agent_id not in agent_ids:
|
|
283
|
-
console.print(f"[red]
|
|
377
|
+
console.print(f"[red]{t('agent_not_found', name=command)}[/red]")
|
|
284
378
|
sys.exit(1)
|
|
285
379
|
_show_agent_dashboard(agent_id)
|
|
286
380
|
return
|
|
@@ -290,7 +384,7 @@ def main():
|
|
|
290
384
|
if agent_filter:
|
|
291
385
|
agent_id = AGENT_ALIASES[agent_filter]
|
|
292
386
|
if agent_id not in agent_ids:
|
|
293
|
-
console.print(f"[red]
|
|
387
|
+
console.print(f"[red]{t('agent_not_found', name=agent_filter)}[/red]")
|
|
294
388
|
sys.exit(1)
|
|
295
389
|
_show_agent_dashboard(agent_id)
|
|
296
390
|
elif len(agents) > 1 and sys.stdin.isatty():
|
|
@@ -301,32 +395,38 @@ def main():
|
|
|
301
395
|
|
|
302
396
|
# 其他命令使用合并数据
|
|
303
397
|
agent_names = [a.name for a in agents]
|
|
398
|
+
rest_args, sort_key, sort_desc = _parse_sort_args(args[1:])
|
|
304
399
|
|
|
305
400
|
if command == "daily":
|
|
306
401
|
stats = _aggregate_per_agent(agents, aggregate_daily)
|
|
307
|
-
|
|
402
|
+
default_attr = "date" if sort_key == "time" else "total_tokens"
|
|
403
|
+
_apply_sort(stats, sort_key, sort_desc, default_attr, default_reverse=True)
|
|
308
404
|
render_daily(stats, agents=agent_names)
|
|
309
405
|
elif command == "weekly":
|
|
310
406
|
stats = _aggregate_per_agent(agents, aggregate_weekly)
|
|
311
|
-
|
|
407
|
+
default_attr = "week"
|
|
408
|
+
_apply_sort(stats, sort_key, sort_desc, default_attr, default_reverse=True)
|
|
312
409
|
render_weekly(stats, agents=agent_names)
|
|
313
410
|
elif command == "monthly":
|
|
314
411
|
stats = _aggregate_per_agent(agents, aggregate_monthly)
|
|
315
|
-
|
|
412
|
+
default_attr = "month"
|
|
413
|
+
_apply_sort(stats, sort_key, sort_desc, default_attr, default_reverse=False)
|
|
316
414
|
render_monthly(stats, agents=agent_names)
|
|
317
415
|
elif command == "sessions":
|
|
318
416
|
limit = 20
|
|
319
|
-
|
|
417
|
+
for a in rest_args:
|
|
320
418
|
try:
|
|
321
|
-
limit = int(
|
|
419
|
+
limit = int(a)
|
|
420
|
+
break
|
|
322
421
|
except ValueError:
|
|
323
422
|
pass
|
|
324
423
|
stats = _aggregate_per_agent(agents, aggregate_sessions)
|
|
325
|
-
|
|
424
|
+
default_attr = "start_time"
|
|
425
|
+
_apply_sort(stats, sort_key, sort_desc, default_attr, default_reverse=True)
|
|
326
426
|
render_sessions(stats, limit)
|
|
327
427
|
else:
|
|
328
|
-
console.print(f"[red]
|
|
329
|
-
console.print("[dim]
|
|
428
|
+
console.print(f"[red]{t('unknown_cmd', cmd=command)}[/red]")
|
|
429
|
+
console.print(f"[dim]{t('available_cmds')}[/dim]")
|
|
330
430
|
sys.exit(1)
|
|
331
431
|
|
|
332
432
|
|
|
@@ -5,13 +5,14 @@ import stat
|
|
|
5
5
|
import sys
|
|
6
6
|
import tomllib
|
|
7
7
|
|
|
8
|
+
from .i18n import t
|
|
8
9
|
from .ui.tables import console
|
|
9
10
|
|
|
10
11
|
CLAUDE_SETTINGS = os.path.expanduser("~/.claude/settings.json")
|
|
11
12
|
HOOK_SCRIPT_PATH = os.path.expanduser("~/.claude/tt-statusline.py")
|
|
12
13
|
CODEX_CONFIG = os.path.expanduser("~/.codex/config.toml")
|
|
13
14
|
CODEX_BACKUP = os.path.expanduser("~/.codex/tt-backup.json")
|
|
14
|
-
HOOK_VERSION = "1.
|
|
15
|
+
HOOK_VERSION = "1.6"
|
|
15
16
|
_BACKUP_KEY = "tokenTracker"
|
|
16
17
|
_PREV_SL_KEY = "previousStatusLine"
|
|
17
18
|
_SL_REGEX = re.compile(r'status_line\s*=\s*\[.*?\]', re.DOTALL)
|
|
@@ -26,17 +27,41 @@ CODEX_STATUS_LINE = [
|
|
|
26
27
|
|
|
27
28
|
HOOK_SCRIPT = r'''#!/usr/bin/env python3
|
|
28
29
|
"""Claude Code statusLine — 状态栏显示 + 数据持久化到 tt-status.json"""
|
|
29
|
-
__version__ = "1.
|
|
30
|
+
__version__ = "1.6"
|
|
30
31
|
import json, os, re, subprocess, sys, tempfile
|
|
31
32
|
from datetime import datetime, timezone
|
|
32
33
|
|
|
33
34
|
STATUS_FILE = os.path.expanduser("~/.claude/tt-status.json")
|
|
34
35
|
ANSI_RE = re.compile(r'\033\[[0-9;]*m')
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
|
|
36
|
+
THEME = "mocha"
|
|
37
|
+
THEMES = {
|
|
38
|
+
"default": {
|
|
39
|
+
"project": "\033[32m", "branch": "\033[35m", "label": "\033[34m",
|
|
40
|
+
"bar_ok": "\033[32m", "bar_warn": "\033[33m", "bar_danger": "\033[31m",
|
|
41
|
+
"tokens": "\033[33m", "duration": "\033[2;35m", "model": "\033[2;35m",
|
|
42
|
+
"reset": "\033[0m",
|
|
43
|
+
},
|
|
44
|
+
"mocha": {
|
|
45
|
+
"project": "\033[38;5;120m", "branch": "\033[38;5;211m", "label": "\033[38;5;218m",
|
|
46
|
+
"bar_ok": "\033[38;5;151m", "bar_warn": "\033[38;5;229m", "bar_danger": "\033[38;5;217m",
|
|
47
|
+
"tokens": "\033[38;5;223m", "duration": "\033[38;5;111m", "model": "\033[38;5;111m",
|
|
48
|
+
"reset": "\033[0m",
|
|
49
|
+
},
|
|
50
|
+
"dracula": {
|
|
51
|
+
"project": "\033[38;5;84m", "branch": "\033[38;5;75m", "label": "\033[38;5;212m",
|
|
52
|
+
"bar_ok": "\033[38;5;151m", "bar_warn": "\033[38;5;229m", "bar_danger": "\033[38;5;217m",
|
|
53
|
+
"tokens": "\033[38;5;215m", "duration": "\033[38;5;117m", "model": "\033[38;5;117m",
|
|
54
|
+
"reset": "\033[0m",
|
|
55
|
+
},
|
|
39
56
|
}
|
|
57
|
+
def _supports_256color():
|
|
58
|
+
if os.environ.get("COLORTERM", "") in ("truecolor", "24bit"):
|
|
59
|
+
return True
|
|
60
|
+
if "256color" in os.environ.get("TERM", ""):
|
|
61
|
+
return True
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
C = THEMES.get(THEME, THEMES["default"]) if _supports_256color() or THEME == "default" else THEMES["default"]
|
|
40
65
|
|
|
41
66
|
if sys.platform == "win32":
|
|
42
67
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
@@ -63,7 +88,7 @@ def get_width():
|
|
|
63
88
|
|
|
64
89
|
|
|
65
90
|
def color_by_pct(pct):
|
|
66
|
-
return C["
|
|
91
|
+
return C["bar_ok"] if pct < 50 else C["bar_warn"] if pct < 80 else C["bar_danger"]
|
|
67
92
|
|
|
68
93
|
|
|
69
94
|
def fmt_tokens(n):
|
|
@@ -78,7 +103,7 @@ def progress_bar(value, bar_width=8):
|
|
|
78
103
|
return empty_char * bar_width + " n/a"
|
|
79
104
|
pct = max(0.0, min(100.0, float(value)))
|
|
80
105
|
filled = round(pct / 100 * bar_width)
|
|
81
|
-
return f"{color_by_pct(pct)}{filled_char * filled}{C['reset']}{empty_char * (bar_width - filled)} {pct:.0f}%"
|
|
106
|
+
return f"{color_by_pct(pct)}{filled_char * filled}{C['reset']}{empty_char * (bar_width - filled)} {C['label']}{pct:.0f}%{C['reset']}"
|
|
82
107
|
|
|
83
108
|
|
|
84
109
|
def fmt_duration(seconds):
|
|
@@ -144,9 +169,9 @@ def render(data, now):
|
|
|
144
169
|
name = os.path.basename(project)
|
|
145
170
|
branch = git_branch(project)
|
|
146
171
|
if branch:
|
|
147
|
-
line1.append(f"{C['
|
|
172
|
+
line1.append(f"{C['project']}{name}{C['reset']}({C['branch']}{branch}{C['reset']})")
|
|
148
173
|
else:
|
|
149
|
-
line1.append(f"{C['
|
|
174
|
+
line1.append(f"{C['project']}{name}{C['reset']}")
|
|
150
175
|
|
|
151
176
|
rl = data.get("rate_limits") or {}
|
|
152
177
|
rl_parts = []
|
|
@@ -159,19 +184,19 @@ def render(data, now):
|
|
|
159
184
|
if resets_at:
|
|
160
185
|
remain = int(resets_at) - int(now.timestamp())
|
|
161
186
|
if remain > 0:
|
|
162
|
-
reset_str = f" {C['
|
|
187
|
+
reset_str = f" \033[2m{C['label']}({fmt_duration(remain)}){C['reset']}"
|
|
163
188
|
rl_parts.append((
|
|
164
|
-
f"{C['
|
|
165
|
-
f"{C['
|
|
166
|
-
f"{C['
|
|
189
|
+
f"{C['label']}{label}:{C['reset']}{progress_bar(pct, bar_w)}{reset_str}",
|
|
190
|
+
f"{C['label']}{label}:{C['reset']}{progress_bar(pct, bar_w)}",
|
|
191
|
+
f"{C['label']}{label}:{pct:.0f}%{C['reset']}",
|
|
167
192
|
))
|
|
168
193
|
|
|
169
194
|
ctx_parts = []
|
|
170
195
|
if ctx.get("used_percentage") is not None:
|
|
171
196
|
size = ctx.get("context_window_size", 0)
|
|
172
197
|
ctx_parts = [
|
|
173
|
-
f"{C['
|
|
174
|
-
f"{C['
|
|
198
|
+
f"{C['label']}{fmt_tokens(size)} Context:{C['reset']}{progress_bar(ctx['used_percentage'], bar_w)}",
|
|
199
|
+
f"{C['label']}{fmt_tokens(size)} CTX:{ctx['used_percentage']:.0f}%{C['reset']}",
|
|
175
200
|
]
|
|
176
201
|
|
|
177
202
|
# 尝试完整版(带进度条+reset time)
|
|
@@ -198,19 +223,19 @@ def render(data, now):
|
|
|
198
223
|
curr_usage = (ctx.get("current_usage") or {})
|
|
199
224
|
turn_in_total = curr_usage.get("input_tokens", 0) + curr_usage.get("cache_creation_input_tokens", 0)
|
|
200
225
|
turn_out = curr_usage.get("output_tokens", 0)
|
|
201
|
-
turn_str = f" {C['
|
|
226
|
+
turn_str = f" \033[2m{C['tokens']}(本轮: in {fmt_tokens(turn_in_total)}, out {fmt_tokens(turn_out)}){C['reset']}"
|
|
202
227
|
if total_in or total_out:
|
|
203
|
-
tok_full = f"{C['
|
|
204
|
-
tok_short = f"{C['
|
|
228
|
+
tok_full = f"{C['tokens']}Tokens: in {fmt_tokens(total_in)}, out {fmt_tokens(total_out)}{turn_str}"
|
|
229
|
+
tok_short = f"{C['tokens']}Tokens: in {fmt_tokens(total_in)}, out {fmt_tokens(total_out)}{C['reset']}"
|
|
205
230
|
line2.append(tok_full)
|
|
206
231
|
cache_read = curr_usage.get("cache_read_input_tokens", 0)
|
|
207
232
|
if cache_read > 0:
|
|
208
|
-
line2.append(f"{C['
|
|
233
|
+
line2.append(f"{C['tokens']}Cached: {fmt_tokens(cache_read)}{C['reset']}")
|
|
209
234
|
|
|
210
235
|
cost = data.get("cost") or {}
|
|
211
236
|
usd = cost.get("total_cost_usd")
|
|
212
237
|
if usd is not None:
|
|
213
|
-
line2.append(f"{C['
|
|
238
|
+
line2.append(f"{C['tokens']}Cost: ${usd:.2f}{C['reset']}")
|
|
214
239
|
|
|
215
240
|
# 宽度不够时隐藏本轮数据
|
|
216
241
|
if vlen(" | ".join(line2)) > W and (total_in or total_out):
|
|
@@ -218,23 +243,24 @@ def render(data, now):
|
|
|
218
243
|
if vlen(" | ".join(line2)) > W:
|
|
219
244
|
line2 = line2[1:]
|
|
220
245
|
|
|
221
|
-
# --- Line 3:
|
|
246
|
+
# --- Line 3: Model | Duration ---
|
|
222
247
|
line3 = []
|
|
223
248
|
|
|
224
|
-
duration_ms = cost.get("total_duration_ms")
|
|
225
|
-
duration_part = ""
|
|
226
|
-
if duration_ms and duration_ms > 0:
|
|
227
|
-
duration_part = f"{C['dim']}{C['magenta']}会话时长: {fmt_duration(duration_ms / 1000)}{C['reset']}"
|
|
228
|
-
line3.append(duration_part)
|
|
229
|
-
|
|
230
249
|
model_name = (data.get("model") or {}).get("display_name", "")
|
|
231
250
|
if model_name:
|
|
251
|
+
model_name = re.sub(r'\s*\(.*?\)', '', model_name)
|
|
232
252
|
effort = (data.get("effort") or {}).get("level", "")
|
|
233
253
|
if effort:
|
|
234
254
|
model_name += f"/{effort}"
|
|
235
255
|
fast = data.get("fast_mode")
|
|
236
256
|
model_name += f"/{'fast' if fast else 'nofast'}"
|
|
237
|
-
line3.append(f"{C['
|
|
257
|
+
line3.append(f"{C['model']}Model: {model_name}{C['reset']}")
|
|
258
|
+
|
|
259
|
+
duration_ms = cost.get("total_duration_ms")
|
|
260
|
+
duration_part = ""
|
|
261
|
+
if duration_ms and duration_ms > 0:
|
|
262
|
+
duration_part = f"{C['duration']}Duration: {fmt_duration(duration_ms / 1000)}{C['reset']}"
|
|
263
|
+
line3.append(duration_part)
|
|
238
264
|
|
|
239
265
|
# 宽度不够时隐藏会话时长
|
|
240
266
|
if vlen(" | ".join(line3)) > W and duration_part:
|
|
@@ -338,23 +364,23 @@ def setup(auto: bool = False) -> None:
|
|
|
338
364
|
has_codex = os.path.exists(CODEX_CONFIG)
|
|
339
365
|
|
|
340
366
|
if not has_cc and not has_codex:
|
|
341
|
-
console.print("[red]
|
|
367
|
+
console.print(f"[red]{t('no_agent_install')}[/red]")
|
|
342
368
|
return
|
|
343
369
|
|
|
344
370
|
if auto:
|
|
345
|
-
console.print("[dim]
|
|
371
|
+
console.print(f"[dim]{t('first_setup')}[/dim]")
|
|
346
372
|
|
|
347
373
|
if has_cc:
|
|
348
374
|
_setup_claude()
|
|
349
375
|
else:
|
|
350
376
|
if not auto:
|
|
351
|
-
console.print("[dim]
|
|
377
|
+
console.print(f"[dim]{t('cc_not_found')}[/dim]")
|
|
352
378
|
|
|
353
379
|
if has_codex:
|
|
354
380
|
_setup_codex()
|
|
355
381
|
else:
|
|
356
382
|
if not auto:
|
|
357
|
-
console.print("[dim]
|
|
383
|
+
console.print(f"[dim]{t('codex_not_found')}[/dim]")
|
|
358
384
|
|
|
359
385
|
|
|
360
386
|
def _setup_claude() -> None:
|
|
@@ -367,7 +393,7 @@ def _setup_claude() -> None:
|
|
|
367
393
|
|
|
368
394
|
existing = settings.get("statusLine")
|
|
369
395
|
if existing and "tt-statusline" not in (existing.get("command") or ""):
|
|
370
|
-
console.print(f"[yellow]
|
|
396
|
+
console.print(f"[yellow]{t('sl_backup_replace')}[/yellow]")
|
|
371
397
|
settings.setdefault(_BACKUP_KEY, {})[_PREV_SL_KEY] = existing
|
|
372
398
|
|
|
373
399
|
python = sys.executable or "python3"
|
|
@@ -376,8 +402,8 @@ def _setup_claude() -> None:
|
|
|
376
402
|
with open(CLAUDE_SETTINGS, "w", encoding="utf-8") as f:
|
|
377
403
|
json.dump(settings, f, indent=2, ensure_ascii=False)
|
|
378
404
|
|
|
379
|
-
console.print(f"[green]✓[/green]
|
|
380
|
-
console.print("[dim]
|
|
405
|
+
console.print(f"[green]✓[/green] {t('cc_configured')}")
|
|
406
|
+
console.print(f"[dim]{t('restart_cc')}[/dim]")
|
|
381
407
|
|
|
382
408
|
|
|
383
409
|
def _setup_codex() -> None:
|
|
@@ -388,7 +414,7 @@ def _setup_codex() -> None:
|
|
|
388
414
|
|
|
389
415
|
old = parsed.get("tui", {}).get("status_line")
|
|
390
416
|
if old == CODEX_STATUS_LINE:
|
|
391
|
-
console.print("[dim]
|
|
417
|
+
console.print(f"[dim]{t('codex_already')}[/dim]")
|
|
392
418
|
return
|
|
393
419
|
|
|
394
420
|
if old is not None:
|
|
@@ -403,10 +429,10 @@ def _setup_codex() -> None:
|
|
|
403
429
|
with open(CODEX_CONFIG, "w", encoding="utf-8") as f:
|
|
404
430
|
f.write(content)
|
|
405
431
|
|
|
406
|
-
console.print(f"[green]✓[/green]
|
|
432
|
+
console.print(f"[green]✓[/green] {t('codex_configured')}")
|
|
407
433
|
if old is not None:
|
|
408
|
-
console.print(f"[dim]
|
|
409
|
-
console.print("[dim]
|
|
434
|
+
console.print(f"[dim]{t('codex_backup', path=CODEX_BACKUP)}[/dim]")
|
|
435
|
+
console.print(f"[dim]{t('restart_codex')}[/dim]")
|
|
410
436
|
|
|
411
437
|
|
|
412
438
|
# --- unsetup ---
|
|
@@ -420,13 +446,13 @@ def unsetup() -> None:
|
|
|
420
446
|
if has_codex:
|
|
421
447
|
_unsetup_codex()
|
|
422
448
|
if not has_cc and not has_codex:
|
|
423
|
-
console.print("[dim]
|
|
449
|
+
console.print(f"[dim]{t('no_agent_detected')}[/dim]")
|
|
424
450
|
|
|
425
451
|
|
|
426
452
|
def _unsetup_claude() -> None:
|
|
427
453
|
if os.path.exists(HOOK_SCRIPT_PATH):
|
|
428
454
|
os.remove(HOOK_SCRIPT_PATH)
|
|
429
|
-
console.print(f"[green]✓[/green]
|
|
455
|
+
console.print(f"[green]✓[/green] {t('deleted_file', path=HOOK_SCRIPT_PATH)}")
|
|
430
456
|
|
|
431
457
|
if not os.path.exists(CLAUDE_SETTINGS):
|
|
432
458
|
return
|
|
@@ -436,16 +462,16 @@ def _unsetup_claude() -> None:
|
|
|
436
462
|
|
|
437
463
|
sl = settings.get("statusLine")
|
|
438
464
|
if not isinstance(sl, dict) or "tt-statusline" not in (sl.get("command") or ""):
|
|
439
|
-
console.print("[dim]
|
|
465
|
+
console.print(f"[dim]{t('sl_not_tt')}[/dim]")
|
|
440
466
|
return
|
|
441
467
|
|
|
442
468
|
previous = settings.get(_BACKUP_KEY, {}).get(_PREV_SL_KEY)
|
|
443
469
|
if isinstance(previous, dict):
|
|
444
470
|
settings["statusLine"] = previous
|
|
445
|
-
console.print(f"[green]✓[/green]
|
|
471
|
+
console.print(f"[green]✓[/green] {t('cc_restored')}")
|
|
446
472
|
else:
|
|
447
473
|
settings.pop("statusLine", None)
|
|
448
|
-
console.print(f"[green]✓[/green]
|
|
474
|
+
console.print(f"[green]✓[/green] {t('cc_removed')}")
|
|
449
475
|
|
|
450
476
|
backup = settings.get(_BACKUP_KEY)
|
|
451
477
|
if isinstance(backup, dict):
|
|
@@ -459,7 +485,7 @@ def _unsetup_claude() -> None:
|
|
|
459
485
|
status_file = os.path.expanduser("~/.claude/tt-status.json")
|
|
460
486
|
if os.path.exists(status_file):
|
|
461
487
|
os.remove(status_file)
|
|
462
|
-
console.print(f"[green]✓[/green]
|
|
488
|
+
console.print(f"[green]✓[/green] {t('deleted_cache', path=status_file)}")
|
|
463
489
|
|
|
464
490
|
|
|
465
491
|
def _unsetup_codex() -> None:
|
|
@@ -476,10 +502,10 @@ def _unsetup_codex() -> None:
|
|
|
476
502
|
old_items = json.load(f).get("status_line", [])
|
|
477
503
|
content = _SL_REGEX.sub(_status_line_toml(old_items), content)
|
|
478
504
|
os.remove(CODEX_BACKUP)
|
|
479
|
-
console.print(f"[green]✓[/green]
|
|
505
|
+
console.print(f"[green]✓[/green] {t('codex_restored')}")
|
|
480
506
|
else:
|
|
481
507
|
content = re.sub(r'status_line\s*=\s*\[.*?\]\n?', '', content, flags=re.DOTALL)
|
|
482
|
-
console.print(f"[green]✓[/green]
|
|
508
|
+
console.print(f"[green]✓[/green] {t('codex_removed')}")
|
|
483
509
|
|
|
484
510
|
with open(CODEX_CONFIG, "w", encoding="utf-8") as f:
|
|
485
511
|
f.write(content)
|