codex-statusline 0.7.0__tar.gz → 0.7.2__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 (37) hide show
  1. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/PKG-INFO +10 -9
  2. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/README.md +9 -8
  3. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/codex_statusline.egg-info/PKG-INFO +10 -9
  4. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/pyproject.toml +1 -1
  5. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/src/__init__.py +1 -1
  6. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/src/claude.py +20 -5
  7. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/src/configure.py +3 -2
  8. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/src/data.py +19 -18
  9. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/src/manage.py +0 -19
  10. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/src/render.py +18 -5
  11. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/src/update.py +1 -1
  12. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/tests/test_claude.py +34 -0
  13. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/tests/test_configure.py +34 -12
  14. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/tests/test_data.py +22 -1
  15. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/tests/test_fish.py +0 -10
  16. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/tests/test_render.py +21 -0
  17. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/LICENSE +0 -0
  18. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/codex_statusline.egg-info/SOURCES.txt +0 -0
  19. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/codex_statusline.egg-info/dependency_links.txt +0 -0
  20. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/codex_statusline.egg-info/entry_points.txt +0 -0
  21. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/codex_statusline.egg-info/top_level.txt +0 -0
  22. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/setup.cfg +0 -0
  23. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/src/__main__.py +0 -0
  24. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/src/cli.py +0 -0
  25. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/src/doctor.py +0 -0
  26. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/src/entry.py +0 -0
  27. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/src/git.py +0 -0
  28. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/src/hook.py +0 -0
  29. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/src/launcher.py +0 -0
  30. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/tests/test_auto_shells.py +0 -0
  31. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/tests/test_doctor.py +0 -0
  32. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/tests/test_git.py +0 -0
  33. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/tests/test_line2.py +0 -0
  34. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/tests/test_management.py +0 -0
  35. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/tests/test_startup.py +0 -0
  36. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/tests/test_tmux.py +0 -0
  37. {codex_statusline-0.7.0 → codex_statusline-0.7.2}/tests/test_update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-statusline
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Status bar for OpenAI Codex CLI and Claude Code: context usage, 5h and weekly quota with reset times, tokens, and cost at a glance. Local-only.
5
5
  Author: Zimo Xu
6
6
  License-Expression: MIT
@@ -65,16 +65,16 @@ Missing tmux? `brew install tmux` or `sudo apt install tmux`.
65
65
 
66
66
  ## Claude Code
67
67
 
68
- The same bar works in [Claude Code](https://code.claude.com/docs/en/statusline): `cxbar install` sets it as Claude Code's `statusLine` when it finds `~/.claude`, so `claude` shows the same two lines, plus the session cost:
68
+ The same bar works in [Claude Code](https://code.claude.com/docs/en/statusline): `cxbar install` sets it as Claude Code's `statusLine` when it finds `~/.claude`, so `claude` shows the current model and the same two lines, plus the session cost:
69
69
 
70
70
  ```text
71
- CTX USED ███░░░░░ 35% | 5h ██░░░░░░░░ 18% 3:46pm | week ██████░░░░ 62% Sat 1:46pm | tok 1.2M
71
+ Opus 5.5 | CTX USED ███░░░░░ 35% | 5h ██░░░░░░░░ 18% 3:46pm | week ██████░░░░ 62% Sat 1:46pm
72
72
  in 1.2M · 94% cached · out 44.0k · ≈$1.84
73
73
  ```
74
74
 
75
75
  - **Keeps what you have.** Already using another statusLine? Install leaves it alone; `cxbar install --claude` switches, and `cxbar uninstall` puts yours back.
76
76
  - **No tmux needed** on the Claude side: Claude Code passes the numbers to the bar directly.
77
- - **Every account.** Pro and Max see 5h and weekly quota with reset times; API-key users see context, tokens, and cost.
77
+ - **Every account.** Subscribers see 5h and weekly quota with reset times; plans without a 5-hour window (Claude Ultra, Codex Pro) show only the weekly quota. API-key users see context, tokens, and cost.
78
78
  - **One config.** `cxbar config` changes the bar in both tools.
79
79
 
80
80
  ## Usage
@@ -127,13 +127,15 @@ Run `cxbar config` to set everything up in an interactive screen with a live, co
127
127
 
128
128
  The second line has four parts:
129
129
 
130
- - `usage` (on by default): tokens sent, how much of that hit the cache, and tokens generated.
130
+ - `usage` (on by default): tokens sent, how much of that hit the cache, and tokens generated. In Claude Code these are the tokens currently in context, as Claude Code reports them, with the session-wide cache hit rate; that is also why the `tokens` segment is off there by default.
131
131
  - `cost` (on by default): the session's estimated cost at list price. Claude Code only.
132
132
  - `git` (on by default): the branch and how many lines are added and removed since the last commit, counting new files the agent created too, e.g. `⎇ main +42 -10`. Hidden outside a git repo.
133
133
  - `pace` (off by default): when your 5h and weekly quota run out at the rate you have used them so far, e.g. `week pace: runs out ~Tue 1:27am`, or `lasts to reset`.
134
134
 
135
135
  Turn them all off to keep a single line.
136
136
 
137
+ The first-line `model` segment shows the current model, including its version when available. It is on by default in Claude Code and off in Codex, whose own footer already shows the model. Toggle or reorder it in `cxbar config`, or set `segments` per tool below. Codex does not supply session cost, so enabling `cost` cannot produce a dollar amount there. The `git` segment appears only when the session's working directory is inside a Git repository.
138
+
137
139
  It writes `~/.config/codex-statusline/config.toml`, which you can also edit by hand:
138
140
 
139
141
  ```toml
@@ -147,6 +149,7 @@ update_check = true # show "↑ update available" in the bar when one is out
147
149
  line2 = ["usage", "cost", "git"] # second line: any of "usage", "cost", "git", "pace"; [] for none
148
150
 
149
151
  [claude]
152
+ segments = ["model", "ctx", "5h", "week"]
150
153
  line2 = ["usage", "cost", "git", "pace"] # e.g. pace only in Claude Code
151
154
  ```
152
155
 
@@ -160,8 +163,6 @@ Invalid values are reported and fall back to defaults.
160
163
 
161
164
  ## FAQ
162
165
 
163
- **How do I scroll or copy?** Exactly as in plain Codex: the mouse wheel scrolls the conversation, and text selection works the same way.
164
-
165
- **Windows?** Not supported (tmux).
166
-
167
166
  **Is this official?** No. It is a third-party tool built on a version-sensitive integration.
167
+
168
+ **Windows?** Not supported.
@@ -44,16 +44,16 @@ Missing tmux? `brew install tmux` or `sudo apt install tmux`.
44
44
 
45
45
  ## Claude Code
46
46
 
47
- The same bar works in [Claude Code](https://code.claude.com/docs/en/statusline): `cxbar install` sets it as Claude Code's `statusLine` when it finds `~/.claude`, so `claude` shows the same two lines, plus the session cost:
47
+ The same bar works in [Claude Code](https://code.claude.com/docs/en/statusline): `cxbar install` sets it as Claude Code's `statusLine` when it finds `~/.claude`, so `claude` shows the current model and the same two lines, plus the session cost:
48
48
 
49
49
  ```text
50
- CTX USED ███░░░░░ 35% | 5h ██░░░░░░░░ 18% 3:46pm | week ██████░░░░ 62% Sat 1:46pm | tok 1.2M
50
+ Opus 5.5 | CTX USED ███░░░░░ 35% | 5h ██░░░░░░░░ 18% 3:46pm | week ██████░░░░ 62% Sat 1:46pm
51
51
  in 1.2M · 94% cached · out 44.0k · ≈$1.84
52
52
  ```
53
53
 
54
54
  - **Keeps what you have.** Already using another statusLine? Install leaves it alone; `cxbar install --claude` switches, and `cxbar uninstall` puts yours back.
55
55
  - **No tmux needed** on the Claude side: Claude Code passes the numbers to the bar directly.
56
- - **Every account.** Pro and Max see 5h and weekly quota with reset times; API-key users see context, tokens, and cost.
56
+ - **Every account.** Subscribers see 5h and weekly quota with reset times; plans without a 5-hour window (Claude Ultra, Codex Pro) show only the weekly quota. API-key users see context, tokens, and cost.
57
57
  - **One config.** `cxbar config` changes the bar in both tools.
58
58
 
59
59
  ## Usage
@@ -106,13 +106,15 @@ Run `cxbar config` to set everything up in an interactive screen with a live, co
106
106
 
107
107
  The second line has four parts:
108
108
 
109
- - `usage` (on by default): tokens sent, how much of that hit the cache, and tokens generated.
109
+ - `usage` (on by default): tokens sent, how much of that hit the cache, and tokens generated. In Claude Code these are the tokens currently in context, as Claude Code reports them, with the session-wide cache hit rate; that is also why the `tokens` segment is off there by default.
110
110
  - `cost` (on by default): the session's estimated cost at list price. Claude Code only.
111
111
  - `git` (on by default): the branch and how many lines are added and removed since the last commit, counting new files the agent created too, e.g. `⎇ main +42 -10`. Hidden outside a git repo.
112
112
  - `pace` (off by default): when your 5h and weekly quota run out at the rate you have used them so far, e.g. `week pace: runs out ~Tue 1:27am`, or `lasts to reset`.
113
113
 
114
114
  Turn them all off to keep a single line.
115
115
 
116
+ The first-line `model` segment shows the current model, including its version when available. It is on by default in Claude Code and off in Codex, whose own footer already shows the model. Toggle or reorder it in `cxbar config`, or set `segments` per tool below. Codex does not supply session cost, so enabling `cost` cannot produce a dollar amount there. The `git` segment appears only when the session's working directory is inside a Git repository.
117
+
116
118
  It writes `~/.config/codex-statusline/config.toml`, which you can also edit by hand:
117
119
 
118
120
  ```toml
@@ -126,6 +128,7 @@ update_check = true # show "↑ update available" in the bar when one is out
126
128
  line2 = ["usage", "cost", "git"] # second line: any of "usage", "cost", "git", "pace"; [] for none
127
129
 
128
130
  [claude]
131
+ segments = ["model", "ctx", "5h", "week"]
129
132
  line2 = ["usage", "cost", "git", "pace"] # e.g. pace only in Claude Code
130
133
  ```
131
134
 
@@ -139,8 +142,6 @@ Invalid values are reported and fall back to defaults.
139
142
 
140
143
  ## FAQ
141
144
 
142
- **How do I scroll or copy?** Exactly as in plain Codex: the mouse wheel scrolls the conversation, and text selection works the same way.
143
-
144
- **Windows?** Not supported (tmux).
145
-
146
145
  **Is this official?** No. It is a third-party tool built on a version-sensitive integration.
146
+
147
+ **Windows?** Not supported.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-statusline
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Status bar for OpenAI Codex CLI and Claude Code: context usage, 5h and weekly quota with reset times, tokens, and cost at a glance. Local-only.
5
5
  Author: Zimo Xu
6
6
  License-Expression: MIT
@@ -65,16 +65,16 @@ Missing tmux? `brew install tmux` or `sudo apt install tmux`.
65
65
 
66
66
  ## Claude Code
67
67
 
68
- The same bar works in [Claude Code](https://code.claude.com/docs/en/statusline): `cxbar install` sets it as Claude Code's `statusLine` when it finds `~/.claude`, so `claude` shows the same two lines, plus the session cost:
68
+ The same bar works in [Claude Code](https://code.claude.com/docs/en/statusline): `cxbar install` sets it as Claude Code's `statusLine` when it finds `~/.claude`, so `claude` shows the current model and the same two lines, plus the session cost:
69
69
 
70
70
  ```text
71
- CTX USED ███░░░░░ 35% | 5h ██░░░░░░░░ 18% 3:46pm | week ██████░░░░ 62% Sat 1:46pm | tok 1.2M
71
+ Opus 5.5 | CTX USED ███░░░░░ 35% | 5h ██░░░░░░░░ 18% 3:46pm | week ██████░░░░ 62% Sat 1:46pm
72
72
  in 1.2M · 94% cached · out 44.0k · ≈$1.84
73
73
  ```
74
74
 
75
75
  - **Keeps what you have.** Already using another statusLine? Install leaves it alone; `cxbar install --claude` switches, and `cxbar uninstall` puts yours back.
76
76
  - **No tmux needed** on the Claude side: Claude Code passes the numbers to the bar directly.
77
- - **Every account.** Pro and Max see 5h and weekly quota with reset times; API-key users see context, tokens, and cost.
77
+ - **Every account.** Subscribers see 5h and weekly quota with reset times; plans without a 5-hour window (Claude Ultra, Codex Pro) show only the weekly quota. API-key users see context, tokens, and cost.
78
78
  - **One config.** `cxbar config` changes the bar in both tools.
79
79
 
80
80
  ## Usage
@@ -127,13 +127,15 @@ Run `cxbar config` to set everything up in an interactive screen with a live, co
127
127
 
128
128
  The second line has four parts:
129
129
 
130
- - `usage` (on by default): tokens sent, how much of that hit the cache, and tokens generated.
130
+ - `usage` (on by default): tokens sent, how much of that hit the cache, and tokens generated. In Claude Code these are the tokens currently in context, as Claude Code reports them, with the session-wide cache hit rate; that is also why the `tokens` segment is off there by default.
131
131
  - `cost` (on by default): the session's estimated cost at list price. Claude Code only.
132
132
  - `git` (on by default): the branch and how many lines are added and removed since the last commit, counting new files the agent created too, e.g. `⎇ main +42 -10`. Hidden outside a git repo.
133
133
  - `pace` (off by default): when your 5h and weekly quota run out at the rate you have used them so far, e.g. `week pace: runs out ~Tue 1:27am`, or `lasts to reset`.
134
134
 
135
135
  Turn them all off to keep a single line.
136
136
 
137
+ The first-line `model` segment shows the current model, including its version when available. It is on by default in Claude Code and off in Codex, whose own footer already shows the model. Toggle or reorder it in `cxbar config`, or set `segments` per tool below. Codex does not supply session cost, so enabling `cost` cannot produce a dollar amount there. The `git` segment appears only when the session's working directory is inside a Git repository.
138
+
137
139
  It writes `~/.config/codex-statusline/config.toml`, which you can also edit by hand:
138
140
 
139
141
  ```toml
@@ -147,6 +149,7 @@ update_check = true # show "↑ update available" in the bar when one is out
147
149
  line2 = ["usage", "cost", "git"] # second line: any of "usage", "cost", "git", "pace"; [] for none
148
150
 
149
151
  [claude]
152
+ segments = ["model", "ctx", "5h", "week"]
150
153
  line2 = ["usage", "cost", "git", "pace"] # e.g. pace only in Claude Code
151
154
  ```
152
155
 
@@ -160,8 +163,6 @@ Invalid values are reported and fall back to defaults.
160
163
 
161
164
  ## FAQ
162
165
 
163
- **How do I scroll or copy?** Exactly as in plain Codex: the mouse wheel scrolls the conversation, and text selection works the same way.
164
-
165
- **Windows?** Not supported (tmux).
166
-
167
166
  **Is this official?** No. It is a third-party tool built on a version-sensitive integration.
167
+
168
+ **Windows?** Not supported.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codex-statusline"
3
- version = "0.7.0"
3
+ version = "0.7.2"
4
4
  description = "Status bar for OpenAI Codex CLI and Claude Code: context usage, 5h and weekly quota with reset times, tokens, and cost at a glance. Local-only."
5
5
  requires-python = ">=3.11"
6
6
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """A local-only tmux status area for the official Codex CLI."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.7.2"
@@ -22,11 +22,21 @@ def _num(value: Any) -> float | None:
22
22
 
23
23
 
24
24
  def state_from(data: dict, now: float) -> dict:
25
+ model = data.get("model") if isinstance(data.get("model"), dict) else {}
26
+ model_id = model.get("id")
27
+ name = model.get("display_name")
28
+ name = name.strip() if isinstance(name, str) else ""
29
+ if isinstance(model_id, str):
30
+ match = re.fullmatch(r"claude-([a-z]+)-(\d+)-(\d+)(?:-\d{8})?(\[1m\])?", model_id)
31
+ if match and (not name or name.casefold() == match[1]):
32
+ name = f"{match[1].capitalize()} {match[2]}.{match[3]}{match[4] or ''}"
33
+ name = name or model_id
25
34
  window = data.get("context_window") if isinstance(data.get("context_window"), dict) else {}
26
35
  limits = data.get("rate_limits") if isinstance(data.get("rate_limits"), dict) else {}
27
36
  cost = data.get("cost") if isinstance(data.get("cost"), dict) else {}
28
37
  state: dict[str, Any] = {
29
38
  "now": now,
39
+ "model": name,
30
40
  "context_used": _num(window.get("used_percentage")),
31
41
  "quota_optional": True, # only subscriptions report rate limits
32
42
  }
@@ -46,13 +56,18 @@ def state_from(data: dict, now: float) -> dict:
46
56
  given, output = _num(window.get("total_input_tokens")), _num(window.get("total_output_tokens"))
47
57
  if given is not None and output is not None:
48
58
  state["tokens"] = given + output
49
- # Claude reports the cache split for the latest request only; use it as the hit rate.
50
- last = window.get("current_usage") if isinstance(window.get("current_usage"), dict) else {}
51
- keys = ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens")
52
- parts = [_num(last.get(k)) or 0.0 for k in keys]
59
+ # Prefer the session-wide hit ratio; older Claude Code only has the latest request's split.
60
+ cache = data.get("prompt_cache") if isinstance(data.get("prompt_cache"), dict) else {}
61
+ ratio = _num(cache.get("hit_ratio"))
62
+ if ratio is None:
63
+ last = window.get("current_usage")
64
+ last = last if isinstance(last, dict) else {}
65
+ keys = ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens")
66
+ parts = [_num(last.get(k)) or 0.0 for k in keys]
67
+ ratio = parts[1] / sum(parts) if sum(parts) else None
53
68
  state["usage"] = {
54
69
  "input_tokens": given,
55
- "cached_input_tokens": given * parts[1] / sum(parts) if sum(parts) else None,
70
+ "cached_input_tokens": given * min(ratio, 1.0) if ratio is not None else None,
56
71
  "output_tokens": output,
57
72
  }
58
73
  state["cost"] = _num(cost.get("total_cost_usd"))
@@ -6,7 +6,6 @@ import re
6
6
  import time
7
7
 
8
8
  from .render import (
9
- DEFAULT_SEGMENTS,
10
9
  LINE2_SEGMENTS,
11
10
  TARGETS,
12
11
  THEMES,
@@ -22,6 +21,7 @@ SEGMENT_NAMES = {
22
21
  "5h": "5-hour quota",
23
22
  "week": "weekly quota",
24
23
  "tokens": "session tokens",
24
+ "model": "current model",
25
25
  }
26
26
  LINE2_NAMES = {
27
27
  "usage": "tokens in / cached / out",
@@ -44,7 +44,7 @@ HELP = "↑↓ move space toggle ←→ change J/K reorder Tab switch tool
44
44
 
45
45
  def model_from(cfg: dict, target: str = "claude") -> dict:
46
46
  enabled = [name for name in cfg["segments"] if name in SEGMENT_NAMES]
47
- order = enabled + [name for name in DEFAULT_SEGMENTS if name not in enabled]
47
+ order = enabled + [name for name in SEGMENT_NAMES if name not in enabled]
48
48
  line2_order = LINE2_FOR[target]
49
49
  return {
50
50
  **{key: cfg[key] for key in SETTINGS},
@@ -150,6 +150,7 @@ def rows_text(model: dict) -> list[str]:
150
150
  def sample_state(target: str = "codex") -> dict:
151
151
  now = time.time()
152
152
  state = {
153
+ "model": "gpt-model" if target == "codex" else "Opus 5.5",
153
154
  "context_used": 35,
154
155
  "tokens": 1_234_567,
155
156
  "usage": {
@@ -99,6 +99,16 @@ def _timestamp(value: Any) -> float | None:
99
99
  return None
100
100
 
101
101
 
102
+ def _window_label(minutes: Any) -> str | None:
103
+ """Name a window by its length, within the same 5% tolerance Codex's own TUI uses."""
104
+ if not _count(minutes):
105
+ return None
106
+ for base, label in ((300, "5h"), (10080, "weekly")):
107
+ if abs(minutes - base) <= base * 0.05:
108
+ return label
109
+ return None
110
+
111
+
102
112
  def _empty_quotas() -> dict[str, dict[str, float | None]]:
103
113
  return {
104
114
  "5h": {"remaining": None, "reset_at": None, "observed_at": None},
@@ -168,12 +178,16 @@ class LogReader:
168
178
  if not isinstance(rate_limits, dict):
169
179
  return
170
180
 
171
- # Slots vary by plan (e.g. Plus may carry only the weekly window in "primary"),
181
+ # Model-specific buckets (e.g. "codex_other") are not the account-wide quota.
182
+ limit_id = rate_limits.get("limit_id")
183
+ if limit_id is not None and limit_id != "codex":
184
+ return
185
+
186
+ # Slots vary by plan (e.g. Pro has no 5h window, so weekly may sit in "primary"),
172
187
  # so identify each quota by its window length, not by its key.
173
- windows = {300: "5h", 10080: "weekly"}
174
188
  for key in ("primary", "secondary"):
175
189
  quota = rate_limits.get(key)
176
- label = windows.get(quota.get("window_minutes")) if isinstance(quota, dict) else None
190
+ label = _window_label(quota.get("window_minutes")) if isinstance(quota, dict) else None
177
191
  if label is None:
178
192
  continue
179
193
  used = quota.get("used_percent")
@@ -233,14 +247,8 @@ class LogReader:
233
247
  probe_stream.seek(self._offset - len(self._probe))
234
248
  overwritten = probe_stream.read(len(self._probe)) != self._probe
235
249
  if identity != self._identity or stat.st_size < self._offset or overwritten:
250
+ self._reset(path)
236
251
  self._identity = identity
237
- self._offset = 0
238
- self._partial = b""
239
- self._discard_until_newline = False
240
- self._probe = b""
241
- self._quotas = _empty_quotas()
242
- self._tokens = None
243
- self._usage = None
244
252
  if stat.st_size > _MAX_READ_BYTES:
245
253
  # Start at a bounded tail boundary, discarding its possibly
246
254
  # incomplete first line. Current quota events remain
@@ -268,14 +276,7 @@ class LogReader:
268
276
  except FileNotFoundError:
269
277
  # A missing bound file has no metadata yet. Retain the selected
270
278
  # path, but a later appearance will be picked up by identity reset.
271
- self._identity = None
272
- self._offset = 0
273
- self._partial = b""
274
- self._discard_until_newline = False
275
- self._probe = b""
276
- self._quotas = _empty_quotas()
277
- self._tokens = None
278
- self._usage = None
279
+ self._reset(path)
279
280
  except OSError:
280
281
  # Avoid exporting filesystem details or turning transient I/O into
281
282
  # fabricated quota data; previously observed metadata remains.
@@ -5,7 +5,6 @@ import json
5
5
  import os
6
6
  import shlex
7
7
  import shutil
8
- import subprocess
9
8
  import sys
10
9
  import time
11
10
  import tomllib
@@ -19,24 +18,6 @@ END = "# <<< codex-statusline <<<"
19
18
  STATUS_MESSAGE = "codex-statusline binding"
20
19
 
21
20
 
22
- def detected_shell():
23
- # $SHELL can remain zsh even when Ghostty's configured command is fish.
24
- try:
25
- proc = subprocess.run(
26
- ["ps", "-p", str(os.getppid()), "-o", "comm="],
27
- capture_output=True,
28
- text=True,
29
- timeout=1,
30
- )
31
- name = Path(proc.stdout.strip()).name.lstrip("-")
32
- if name in ("fish", "zsh", "bash"):
33
- return name
34
- except (OSError, subprocess.TimeoutExpired):
35
- pass
36
- name = Path(os.environ.get("SHELL", "/bin/zsh")).name
37
- return name if name in ("fish", "zsh", "bash") else "zsh"
38
-
39
-
40
21
  def available_shells():
41
22
  """Install for every supported shell available on this machine."""
42
23
  return [name for name in ("bash", "zsh", "fish") if shutil.which(name)]
@@ -53,6 +53,9 @@ def load_config(path: str | os.PathLike | None = None, target: str = "codex") ->
53
53
  if path is None:
54
54
  path = config_path()
55
55
  config = dict(_CONFIG_DEFAULTS)
56
+ if target == "claude":
57
+ # Claude's token counts describe the current context only, which ctx already shows.
58
+ config["segments"] = ["model", "ctx", "5h", "week"]
56
59
  try:
57
60
  raw = tomllib.loads(Path(path).read_text())
58
61
  except FileNotFoundError:
@@ -62,7 +65,9 @@ def load_config(path: str | os.PathLike | None = None, target: str = "codex") ->
62
65
  raw = {}
63
66
  checks = {
64
67
  "segments": lambda v: (
65
- isinstance(v, list) and v and all(x in ("ctx", "5h", "week", "tokens") for x in v)
68
+ isinstance(v, list)
69
+ and v
70
+ and all(x in ("model", "ctx", "5h", "week", "tokens") for x in v)
66
71
  ),
67
72
  "theme": lambda v: v in THEMES,
68
73
  "ascii": lambda v: isinstance(v, bool),
@@ -160,13 +165,12 @@ def _pct(value: Any) -> str:
160
165
  return "--" if number is None else f"{number}%"
161
166
 
162
167
 
163
- def _bar(value: Any, slots: int, ascii_only: bool, *, left: bool = False) -> str:
168
+ def _bar(value: Any, slots: int, ascii_only: bool) -> str:
164
169
  number = _percent(value)
165
170
  if number is None:
166
171
  symbol = "-" if ascii_only else "·"
167
172
  return symbol * slots
168
- ratio = number
169
- filled = round(ratio * slots / 100)
173
+ filled = round(number * slots / 100)
170
174
  if ascii_only:
171
175
  return "=" * filled + "-" * (slots - filled)
172
176
  return "█" * filled + "░" * (slots - filled)
@@ -199,7 +203,7 @@ def _quota_values(
199
203
  left = "--" if remaining is None else f"{remaining}%"
200
204
  reset = _reset(data.get("reset_at"), now, weekly)
201
205
  compact = f"{label} {left}"
202
- bar = _bar(remaining, 10, ascii_only, left=True)
206
+ bar = _bar(remaining, 10, ascii_only)
203
207
  detail = f"{label} {bar} {left} {reset}".rstrip()
204
208
  return detail, compact, remaining
205
209
 
@@ -369,6 +373,11 @@ def render(
369
373
  "week": (qwdetail, qwcompact, _quota_role(qwvalue, warn_at, crit_at)),
370
374
  "tokens": (tok, tok, "accent"),
371
375
  }
376
+ model = state.get("model")
377
+ if isinstance(model, str) and model.strip():
378
+ text = _safe(model, ascii_only, tmux and not ansi).strip()
379
+ if text:
380
+ known["model"] = (text, text, "accent")
372
381
  # Plans without a 5h (or weekly) limit report only the other window: hide the missing one.
373
382
  if (q5value is None) != (qwvalue is None):
374
383
  del known["5h" if q5value is None else "week"]
@@ -378,6 +387,10 @@ def render(
378
387
  parts = [known[name] for name in (segments or DEFAULT_SEGMENTS) if name in known] or [
379
388
  known["ctx"]
380
389
  ]
390
+ parts = [
391
+ (detail if i == 0 else detail.lstrip(), compact if i == 0 else compact.lstrip(), role)
392
+ for i, (detail, compact, role) in enumerate(parts)
393
+ ]
381
394
  update = state.get("update")
382
395
  hint = None
383
396
  if isinstance(update, str) and re.fullmatch(r"\d+(\.\d+)*", update):
@@ -122,7 +122,7 @@ def check(latest=latest_version) -> int:
122
122
 
123
123
  def update() -> int:
124
124
  command = upgrade_command()
125
- print("$ " + " ".join(command))
125
+ print("$ " + " ".join(command), flush=True) # before the tool's own output
126
126
  if not shutil.which(command[0]):
127
127
  print(
128
128
  f"codex-statusline: {command[0]} not found; run the command above yourself.",
@@ -15,6 +15,7 @@ from src.claude import state_from, statusline
15
15
 
16
16
  NOW = time.time()
17
17
  SUBSCRIBER = {
18
+ "model": {"id": "claude-opus-5-5", "display_name": "Opus"},
18
19
  "context_window": {
19
20
  "used_percentage": 35,
20
21
  "total_input_tokens": 1_190_000,
@@ -54,6 +55,7 @@ class ClaudeStatuslineTests(unittest.TestCase):
54
55
  self.assertIn("\x1b[38;5;", out) # ANSI colors for Claude Code
55
56
  plain = ANSI.sub("", out).splitlines()
56
57
  self.assertEqual(len(plain), 2)
58
+ self.assertTrue(plain[0].startswith("Opus 5.5 | CTX USED"), plain[0])
57
59
  self.assertIn("CTX USED", plain[0])
58
60
  self.assertIn("5h ██░░░░░░░░ 18%", plain[0])
59
61
  self.assertIn("in 1.2M · 94% cached · out 44.0k · ≈$1.84", plain[1])
@@ -73,6 +75,21 @@ class ClaudeStatuslineTests(unittest.TestCase):
73
75
  self.assertNotIn("cached", plain)
74
76
  self.assertIn("in 52.0k · out 3.1k · ≈$0.43", plain)
75
77
 
78
+ def test_plan_without_5h_window_shows_weekly_only(self):
79
+ data = dict(SUBSCRIBER, rate_limits={"seven_day": SUBSCRIBER["rate_limits"]["seven_day"]})
80
+ plain = ANSI.sub("", run_statusline(data, self.home))
81
+ self.assertNotIn("5h", plain)
82
+ self.assertIn("week", plain)
83
+
84
+ def test_tokens_segment_off_by_default(self):
85
+ self.assertNotIn("tok ", ANSI.sub("", run_statusline(SUBSCRIBER, self.home)))
86
+
87
+ def test_session_hit_ratio_preferred_over_last_request(self):
88
+ data = dict(SUBSCRIBER, prompt_cache={"hit_ratio": 0.5})
89
+ self.assertIn("50% cached", ANSI.sub("", run_statusline(data, self.home)))
90
+ data = dict(SUBSCRIBER, prompt_cache={"hit_ratio": None})
91
+ self.assertIn("94% cached", ANSI.sub("", run_statusline(data, self.home)))
92
+
76
93
  def test_bad_input_still_prints_a_bar(self):
77
94
  for data in ("not json", "[1, 2]", ""):
78
95
  self.assertIn("CTX USED", run_statusline(data, self.home))
@@ -83,6 +100,23 @@ class ClaudeStatuslineTests(unittest.TestCase):
83
100
  self.assertEqual(state["quotas"]["weekly"]["reset_at"], NOW + 200_000)
84
101
  self.assertEqual(state["tokens"], 1_234_000)
85
102
 
103
+ def test_model_name_fallback_and_configurable_segment(self):
104
+ for model, expected in (
105
+ ({"id": "claude-opus-5-5", "display_name": "Opus"}, "Opus 5.5"),
106
+ ({"id": "claude-sonnet-4-5-20250929"}, "Sonnet 4.5"),
107
+ ({"id": "claude-opus-5-5[1m]", "display_name": "Opus"}, "Opus 5.5[1m]"),
108
+ ({"id": "custom-model", "display_name": "My Model"}, "My Model"),
109
+ ({"id": "custom-model"}, "custom-model"),
110
+ (None, ""),
111
+ ):
112
+ with self.subTest(model=model):
113
+ self.assertEqual(state_from({"model": model}, NOW)["model"], expected)
114
+ config = self.home / "cfg/codex-statusline/config.toml"
115
+ config.parent.mkdir(parents=True)
116
+ config.write_text('[claude]\nsegments = ["ctx", "tokens"]\n')
117
+ plain = ANSI.sub("", run_statusline(SUBSCRIBER, self.home))
118
+ self.assertNotIn("Opus", plain)
119
+
86
120
 
87
121
  class ClaudeInstallTests(unittest.TestCase):
88
122
  def setUp(self):
@@ -3,7 +3,7 @@ import unittest
3
3
  from pathlib import Path
4
4
  from unittest.mock import patch
5
5
 
6
- from src.configure import apply, dump_toml, model_from, rows_text, to_config
6
+ from src.configure import SETTINGS, apply, dump_toml, model_from, rows_text, to_config
7
7
  from src.render import load_config
8
8
 
9
9
 
@@ -17,15 +17,17 @@ class ConfigureTests(unittest.TestCase):
17
17
  row = apply(m, 3, "space") # turn tokens off
18
18
  row = apply(m, 1, "J") # move 5h below week
19
19
  self.assertEqual(row, 2)
20
- apply(m, 8, "right") # theme auto -> dark
21
- apply(m, 10, "left") # warn_at 40 -> 35
22
- apply(m, 11, "left") # crit_at 20 -> 15
23
- apply(m, 11, "left") # 15 -> 10
24
- apply(m, 12, "space") # update notice off
25
- apply(m, 4, "space") # line 2: usage (on by default) -> off
26
- apply(m, 5, "space") # line 2: cost (on by default) -> off
27
- apply(m, 6, "space") # line 2: git (on by default) -> off
28
- apply(m, 7, "space") # line 2: pace on
20
+ line2 = len(m["order"])
21
+ settings = line2 + len(m["line2_order"])
22
+ apply(m, settings, "right") # theme auto -> dark
23
+ apply(m, settings + 2, "left") # warn_at 40 -> 35
24
+ apply(m, settings + 3, "left") # crit_at 20 -> 15
25
+ apply(m, settings + 3, "left") # 15 -> 10
26
+ apply(m, settings + 4, "space") # update notice off
27
+ apply(m, line2, "space") # usage off
28
+ apply(m, line2 + 1, "space") # cost off
29
+ apply(m, line2 + 2, "space") # git off
30
+ apply(m, line2 + 3, "space") # pace on
29
31
  cfg = to_config(m)
30
32
  self.assertEqual(cfg["line2"], ["pace"])
31
33
  self.assertEqual(cfg["segments"], ["ctx", "week", "5h"])
@@ -48,6 +50,23 @@ class ConfigureTests(unittest.TestCase):
48
50
 
49
51
 
50
52
  class TwoToolTests(unittest.TestCase):
53
+ def test_model_toggle_reorder_and_roundtrip(self):
54
+ from src.configure import dump_sections
55
+
56
+ with patch.dict("os.environ", {}, clear=True):
57
+ model = model_from(load_config("/nonexistent.toml", "claude"), "claude")
58
+ self.assertIn("model", model["enabled"])
59
+ row = apply(model, model["order"].index("model"), "J")
60
+ self.assertEqual(row, 1)
61
+ apply(model, row, "space")
62
+ text = dump_sections({"claude": to_config(model)})
63
+ with tempfile.TemporaryDirectory() as tmp:
64
+ path = Path(tmp) / "config.toml"
65
+ path.write_text(text)
66
+ saved = load_config(path, "claude")
67
+ self.assertNotIn("model", saved["segments"])
68
+ self.assertEqual(saved, to_config(model))
69
+
51
70
  def test_each_tool_saves_and_loads_its_own_section(self):
52
71
  from src.configure import dump_sections
53
72
 
@@ -65,7 +84,9 @@ class TwoToolTests(unittest.TestCase):
65
84
  self.assertNotIn("tokens", load_config(path, "codex")["segments"])
66
85
  self.assertEqual(load_config(path, "codex")["theme"], "auto")
67
86
  self.assertEqual(load_config(path, "claude")["theme"], "light")
68
- self.assertIn("tokens", load_config(path, "claude")["segments"])
87
+ self.assertEqual(
88
+ load_config(path, "claude")["segments"], ["model", "ctx", "5h", "week"]
89
+ )
69
90
 
70
91
  def test_old_flat_config_applies_to_both_tools(self):
71
92
  with tempfile.TemporaryDirectory() as tmp:
@@ -94,8 +115,9 @@ class TwoToolTests(unittest.TestCase):
94
115
 
95
116
  with patch.dict("os.environ", {}, clear=True):
96
117
  model = model_from(load_config("/nonexistent.toml", "codex"), "codex")
118
+ row = len(model["order"]) + len(model["line2_order"]) + SETTINGS.index("crit_at")
97
119
  for _ in range(30):
98
- apply(model, 10, "left") # crit_at all the way down
120
+ apply(model, row, "left") # crit_at all the way down
99
121
  self.assertEqual(model["crit_at"], 0)
100
122
  text = dump_sections({"codex": to_config(model)})
101
123
  self.assertEqual(text, "[codex]\ncrit_at = 0\n")
@@ -122,7 +122,7 @@ class LogReaderTests(unittest.TestCase):
122
122
  self.assertIsNone(reader.update(str(second))["tokens"])
123
123
 
124
124
  def test_weekly_only_plan_in_primary_slot(self):
125
- # Plus plans may report only the weekly window, placed in "primary".
125
+ # Plans without a 5h window (e.g. Pro) report only the weekly one, in "primary".
126
126
  event = token_event(1000, primary_used=11, primary_window=10080)
127
127
  event["payload"]["info"]["rate_limits"]["secondary"] = None
128
128
  with tempfile.TemporaryDirectory() as tmp:
@@ -132,6 +132,27 @@ class LogReaderTests(unittest.TestCase):
132
132
  self.assertEqual(quotas["weekly"]["remaining"], 89.0)
133
133
  self.assertIsNone(quotas["5h"]["remaining"])
134
134
 
135
+ def test_windows_match_within_five_percent(self):
136
+ event = token_event(1000, 10, 20, primary_window=299, secondary_window=10100)
137
+ with tempfile.TemporaryDirectory() as tmp:
138
+ path = Path(tmp) / "s.jsonl"
139
+ path.write_bytes(line(event))
140
+ quotas = LogReader().update(str(path))["quotas"]
141
+ self.assertEqual(quotas["5h"]["remaining"], 90.0)
142
+ self.assertEqual(quotas["weekly"]["remaining"], 80.0)
143
+
144
+ def test_model_specific_limit_bucket_is_ignored(self):
145
+ account = token_event(1000, 10, 20)
146
+ account["payload"]["info"]["rate_limits"]["limit_id"] = "codex"
147
+ other = token_event(2000, 90, 90)
148
+ other["payload"]["info"]["rate_limits"]["limit_id"] = "codex_other"
149
+ with tempfile.TemporaryDirectory() as tmp:
150
+ path = Path(tmp) / "s.jsonl"
151
+ path.write_bytes(line(account) + line(other))
152
+ quotas = LogReader().update(str(path))["quotas"]
153
+ self.assertEqual(quotas["5h"]["remaining"], 90.0)
154
+ self.assertEqual(quotas["weekly"]["observed_at"], 1000.0)
155
+
135
156
  def test_newest_quota_across_sessions_wins(self):
136
157
  with tempfile.TemporaryDirectory() as tmp:
137
158
  day = Path(tmp) / "2026" / "09" / "30"
@@ -72,13 +72,3 @@ class FishTests(unittest.TestCase):
72
72
  manage.installation(args, True)
73
73
  self.assertEqual(fish.read_text(), "# later user setting\n")
74
74
  self.assertNotIn(manage.BEGIN, (root / ".zshrc").read_text())
75
-
76
- def test_shell_detection_prefers_parent_to_login_shell(self):
77
- with (
78
- patch.dict(os.environ, {"SHELL": "/bin/zsh"}),
79
- patch(
80
- "subprocess.run",
81
- return_value=subprocess.CompletedProcess([], 0, "/opt/homebrew/bin/fish\n", ""),
82
- ),
83
- ):
84
- self.assertEqual(manage.detected_shell(), "fish")
@@ -217,6 +217,24 @@ class RenderTests(unittest.TestCase):
217
217
  self.assertNotIn("CTX", result)
218
218
  self.assertIn("colour196", render(self.state, 240, segments=["5h"], crit_at=80))
219
219
 
220
+ def test_model_segment_is_optional_safe_and_width_bounded(self):
221
+ segments = ["model", "ctx", "tokens"]
222
+ plain = render(self.state, 240, tmux=False, segments=segments)
223
+ self.assertTrue(plain.startswith("Claude Sonnet 4.5 | CTX USED"), plain)
224
+ self.assertNotIn("Claude", render(self.state, 240, tmux=False))
225
+ hostile = dict(self.state, model="Opus#(touch NEVER)\x1b]0;title\x07\u202e長")
226
+ for width in range(1, 241):
227
+ for tmux, ansi in ((True, False), (False, True), (False, False)):
228
+ out = render(hostile, width, segments=segments, tmux=tmux, ansi=ansi)
229
+ self.assertLessEqual(visible_width(out), width)
230
+ self.assertNotIn("title", out)
231
+ self.assertNotIn("\u202e", out)
232
+ if tmux:
233
+ self.assertIsNone(re.search(r"(?<!#)#\(", out), out)
234
+ for missing in (None, 123, "", "\x1b[31m\u202e"):
235
+ out = render({"model": missing}, 80, tmux=False, segments=["model"])
236
+ self.assertIn("CTX USED", out) # no model metadata yet: retain a useful bar
237
+
220
238
 
221
239
  class ConfigTests(unittest.TestCase):
222
240
  def test_missing_file_defaults_and_invalid_keys_ignored(self):
@@ -227,6 +245,9 @@ class ConfigTests(unittest.TestCase):
227
245
  ):
228
246
  path = Path(tmp) / "config.toml"
229
247
  self.assertEqual(load_config(path)["segments"], ["ctx", "5h", "week", "tokens"])
248
+ self.assertEqual(
249
+ load_config(path, "claude")["segments"], ["model", "ctx", "5h", "week"]
250
+ )
230
251
  path.write_text('segments = ["week", "ctx"]\ntheme = "neon"\nwarn_at = 30\n')
231
252
  cfg = load_config(path)
232
253
  self.assertEqual(