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.
Files changed (26) hide show
  1. {token_tracker-0.3.4 → token_tracker-0.3.6}/PKG-INFO +1 -1
  2. {token_tracker-0.3.4 → token_tracker-0.3.6}/README.md +42 -3
  3. {token_tracker-0.3.4 → token_tracker-0.3.6}/pyproject.toml +1 -1
  4. {token_tracker-0.3.4 → token_tracker-0.3.6}/src/adapters/codex.py +4 -0
  5. {token_tracker-0.3.4 → token_tracker-0.3.6}/src/cli.py +117 -17
  6. {token_tracker-0.3.4 → token_tracker-0.3.6}/src/hooks.py +74 -48
  7. token_tracker-0.3.6/src/i18n.py +196 -0
  8. {token_tracker-0.3.4 → token_tracker-0.3.6}/src/ui/tables.py +93 -91
  9. {token_tracker-0.3.4 → token_tracker-0.3.6}/token_tracker.egg-info/PKG-INFO +1 -1
  10. {token_tracker-0.3.4 → token_tracker-0.3.6}/token_tracker.egg-info/SOURCES.txt +1 -0
  11. {token_tracker-0.3.4 → token_tracker-0.3.6}/setup.cfg +0 -0
  12. {token_tracker-0.3.4 → token_tracker-0.3.6}/src/__init__.py +0 -0
  13. {token_tracker-0.3.4 → token_tracker-0.3.6}/src/adapters/__init__.py +0 -0
  14. {token_tracker-0.3.4 → token_tracker-0.3.6}/src/adapters/claude.py +0 -0
  15. {token_tracker-0.3.4 → token_tracker-0.3.6}/src/adapters/rate_limits.py +0 -0
  16. {token_tracker-0.3.4 → token_tracker-0.3.6}/src/adapters/registry.py +0 -0
  17. {token_tracker-0.3.4 → token_tracker-0.3.6}/src/adapters/types.py +0 -0
  18. {token_tracker-0.3.4 → token_tracker-0.3.6}/src/analyzer/__init__.py +0 -0
  19. {token_tracker-0.3.4 → token_tracker-0.3.6}/src/analyzer/aggregator.py +0 -0
  20. {token_tracker-0.3.4 → token_tracker-0.3.6}/src/analyzer/blocks.py +0 -0
  21. {token_tracker-0.3.4 → token_tracker-0.3.6}/src/analyzer/cost.py +0 -0
  22. {token_tracker-0.3.4 → token_tracker-0.3.6}/src/ui/__init__.py +0 -0
  23. {token_tracker-0.3.4 → token_tracker-0.3.6}/token_tracker.egg-info/dependency_links.txt +0 -0
  24. {token_tracker-0.3.4 → token_tracker-0.3.6}/token_tracker.egg-info/entry_points.txt +0 -0
  25. {token_tracker-0.3.4 → token_tracker-0.3.6}/token_tracker.egg-info/requires.txt +0 -0
  26. {token_tracker-0.3.4 → token_tracker-0.3.6}/token_tracker.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: token-tracker
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: Track token usage across local AI agents (Claude Code, Codex)
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: rich>=13.7
@@ -12,11 +12,28 @@
12
12
 
13
13
  自动为 Claude Code 和 Codex 配置状态栏,`tt setup` 一键配置,脚本更新时自动升级。
14
14
 
15
- **Claude Code**:项目名、5h/7d 配额进度条、CTX 窗口占比、Token 用量、模型名
15
+ **Claude Code**:基于官方自定义 StatusLine 接口,数据完全来自本地 Claude,准确无任何推测
16
16
 
17
17
  ![Claude Code StatusLine](assets/screenshot-statusline-cc.png)
18
18
 
19
- **Codex**:项目名、5h/7d 配额、上下文剩余、模型名
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
  ![Codex StatusLine](assets/screenshot-statusline-codex.png)
22
39
 
@@ -43,7 +60,7 @@
43
60
  ## 安装
44
61
 
45
62
  ```bash
46
- curl -sSL https://raw.githubusercontent.com/stormzhang/token-tracker/master/install.sh | bash
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+
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "token-tracker"
7
- version = "0.3.4"
7
+ version = "0.3.6"
8
8
  description = "Track token usage across local AI agents (Claude Code, Codex)"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -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]暂无 token 使用数据[/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加载数据...\033[0m")
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
- render_dashboard(**data, session_limit=10, top_margin=False)
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]暂无数据[/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]未检测到任何 AI Agent[/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]检测到: {', '.join(a.name + ' ✓' for a in agents)}[/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]未检测到 {command}[/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]未检测到 {agent_filter}[/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
- stats.sort(key=lambda s: s.total_tokens, reverse=True)
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
- stats.sort(key=lambda s: s.week, reverse=True)
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
- stats.sort(key=lambda s: s.month)
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
- if len(args) > 1:
417
+ for a in rest_args:
320
418
  try:
321
- limit = int(args[1])
419
+ limit = int(a)
420
+ break
322
421
  except ValueError:
323
422
  pass
324
423
  stats = _aggregate_per_agent(agents, aggregate_sessions)
325
- stats.sort(key=lambda s: s.start_time, reverse=True)
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]未知命令: {command}[/red]")
329
- console.print("[dim]可用命令: dashboard, daily, weekly, monthly, sessions, claude, codex, setup, unsetup, --version[/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.5"
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.5"
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
- C = {
36
- "green": "\033[32m", "yellow": "\033[33m", "red": "\033[31m",
37
- "cyan": "\033[36m", "blue": "\033[34m", "magenta": "\033[35m",
38
- "peach": "\033[38;5;216m", "dim": "\033[2m", "reset": "\033[0m",
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["green"] if pct < 50 else C["yellow"] if pct < 80 else C["red"]
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['green']}{name}{C['reset']}({C['magenta']}{branch}{C['reset']})")
172
+ line1.append(f"{C['project']}{name}{C['reset']}({C['branch']}{branch}{C['reset']})")
148
173
  else:
149
- line1.append(f"{C['green']}{name}{C['reset']}")
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['dim']}({fmt_duration(remain)}){C['reset']}"
187
+ reset_str = f" \033[2m{C['label']}({fmt_duration(remain)}){C['reset']}"
163
188
  rl_parts.append((
164
- f"{C['blue']}{label}:{C['reset']}{progress_bar(pct, bar_w)}{reset_str}",
165
- f"{C['blue']}{label}:{C['reset']}{progress_bar(pct, bar_w)}",
166
- f"{C['blue']}{label}:{C['reset']}{pct:.0f}%",
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['blue']}{fmt_tokens(size)} Context:{C['reset']}{progress_bar(ctx['used_percentage'], bar_w)}",
174
- f"{C['blue']}{fmt_tokens(size)} CTX:{C['reset']}{ctx['used_percentage']:.0f}%",
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['dim']}(本轮: in {fmt_tokens(turn_in_total)}, out {fmt_tokens(turn_out)}){C['reset']}"
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['peach']}Tokens: in {fmt_tokens(total_in)}, out {fmt_tokens(total_out)}{turn_str}"
204
- tok_short = f"{C['peach']}Tokens: in {fmt_tokens(total_in)}, out {fmt_tokens(total_out)}{C['reset']}"
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['cyan']}Cached: {fmt_tokens(cache_read)}{C['reset']}")
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['magenta']}Cost: ${usd:.2f}{C['reset']}")
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: Duration + Model ---
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['dim']}{C['magenta']}{model_name}{C['reset']}")
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]未检测到 Claude Code 或 Codex,请先安装其中之一[/red]")
367
+ console.print(f"[red]{t('no_agent_install')}[/red]")
342
368
  return
343
369
 
344
370
  if auto:
345
- console.print("[dim]首次使用,正在配置状态栏...[/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]未检测到 Claude Code,跳过[/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]未检测到 Codex,跳过[/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]检测到已有 statusLine,备份后替换[/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] Claude Code statusLine 已配置")
380
- console.print("[dim]重启 Claude Code 后生效[/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]Codex status_line 已是目标配置,跳过[/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] Codex status_line 已配置")
432
+ console.print(f"[green]✓[/green] {t('codex_configured')}")
407
433
  if old is not None:
408
- console.print(f"[dim]原配置已备份到: {CODEX_BACKUP}[/dim]")
409
- console.print("[dim]重启 Codex 后生效[/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]未检测到 Claude Code 或 Codex[/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] 已删除: {HOOK_SCRIPT_PATH}")
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]当前 statusLine 不是 tt-statusline,保留现有配置[/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] Claude Code statusLine 已恢复原配置")
471
+ console.print(f"[green]✓[/green] {t('cc_restored')}")
446
472
  else:
447
473
  settings.pop("statusLine", None)
448
- console.print(f"[green]✓[/green] Claude Code statusLine 已移除")
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] 已删除缓存: {status_file}")
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] Codex status_line 已恢复原配置")
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] Codex status_line 已移除")
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)