codex-statusline 0.4.0__tar.gz → 0.4.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 (33) hide show
  1. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/PKG-INFO +1 -1
  2. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/codex_statusline.egg-info/PKG-INFO +1 -1
  3. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/pyproject.toml +1 -1
  4. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/__init__.py +1 -1
  5. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/configure.py +32 -7
  6. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/data.py +0 -8
  7. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/launcher.py +3 -2
  8. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/render.py +13 -17
  9. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_configure.py +3 -2
  10. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_line2.py +10 -9
  11. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_render.py +21 -0
  12. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/LICENSE +0 -0
  13. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/README.md +0 -0
  14. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/codex_statusline.egg-info/SOURCES.txt +0 -0
  15. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/codex_statusline.egg-info/dependency_links.txt +0 -0
  16. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/codex_statusline.egg-info/entry_points.txt +0 -0
  17. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/codex_statusline.egg-info/top_level.txt +0 -0
  18. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/setup.cfg +0 -0
  19. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/__main__.py +0 -0
  20. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/cli.py +0 -0
  21. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/doctor.py +0 -0
  22. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/entry.py +0 -0
  23. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/hook.py +0 -0
  24. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/manage.py +0 -0
  25. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/update.py +0 -0
  26. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_auto_shells.py +0 -0
  27. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_data.py +0 -0
  28. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_doctor.py +0 -0
  29. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_fish.py +0 -0
  30. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_management.py +0 -0
  31. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_startup.py +0 -0
  32. {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_tmux.py +0 -0
  33. {codex_statusline-0.4.0 → codex_statusline-0.4.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.4.0
3
+ Version: 0.4.2
4
4
  Summary: A tmux statusline for OpenAI Codex CLI: context usage, 5h and weekly quota, and session tokens at a glance. Local-only.
5
5
  Author: Zimo Xu
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-statusline
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: A tmux statusline for OpenAI Codex CLI: context usage, 5h and weekly quota, and session tokens at a glance. Local-only.
5
5
  Author: Zimo Xu
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codex-statusline"
3
- version = "0.4.0"
3
+ version = "0.4.2"
4
4
  description = "A tmux statusline for OpenAI Codex CLI: context usage, 5h and weekly quota, and session tokens 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.4.0"
3
+ __version__ = "0.4.2"
@@ -1,6 +1,7 @@
1
1
  """`codex-statusline config`: pick segments, theme and thresholds with a live preview."""
2
2
 
3
3
  import curses
4
+ import locale
4
5
  import time
5
6
 
6
7
  from .render import (
@@ -20,8 +21,8 @@ SEGMENT_NAMES = {
20
21
  "tokens": "session tokens",
21
22
  }
22
23
  LINE2_NAMES = {
24
+ "usage": "line 2: tokens in / cached / out",
23
25
  "pace": "line 2: when quota runs out at this pace",
24
- "usage": "line 2: tokens in / cached / out, context size",
25
26
  }
26
27
  SETTINGS = ["theme", "ascii", "warn_at", "crit_at", "update_check"]
27
28
  LABELS = {
@@ -132,7 +133,6 @@ def sample_state() -> dict:
132
133
  return {
133
134
  "context_used": 35,
134
135
  "tokens": 1_234_567,
135
- "window": 258_400,
136
136
  "usage": {
137
137
  "input_tokens": 1_190_000,
138
138
  "cached_input_tokens": 1_120_000,
@@ -154,7 +154,7 @@ KEYS = {
154
154
  }
155
155
 
156
156
 
157
- def _ui(screen, model: dict) -> bool:
157
+ def _ui(screen, model: dict, ascii_only: bool = False) -> bool:
158
158
  curses.curs_set(0)
159
159
  row, state = 0, sample_state()
160
160
  while True:
@@ -165,15 +165,22 @@ def _ui(screen, model: dict) -> bool:
165
165
  state,
166
166
  max(1, width - 4),
167
167
  tmux=False,
168
- ascii_only=cfg["ascii"],
168
+ ascii_only=cfg["ascii"] or ascii_only,
169
169
  segments=cfg["segments"],
170
170
  warn_at=cfg["warn_at"],
171
171
  crit_at=cfg["crit_at"],
172
172
  )
173
- lines = ["codex-statusline config", HELP, ""]
173
+ help_text = (
174
+ HELP.replace("↑↓", "up/down").replace("←→", "left/right") if ascii_only else HELP
175
+ )
176
+ lines = ["codex-statusline config", help_text, ""]
174
177
  lines += [("> " if i == row else " ") + text for i, text in enumerate(rows_text(model))]
175
178
  second = render_line2(
176
- state, max(1, width - 4), cfg["line2"], tmux=False, ascii_only=cfg["ascii"]
179
+ state,
180
+ max(1, width - 4),
181
+ cfg["line2"],
182
+ tmux=False,
183
+ ascii_only=cfg["ascii"] or ascii_only,
177
184
  )
178
185
  lines += ["", "Preview (sample numbers):", " " + preview]
179
186
  lines += [" " + second] if second else []
@@ -193,10 +200,28 @@ def _ui(screen, model: dict) -> bool:
193
200
  row = apply(model, row, name)
194
201
 
195
202
 
203
+ def utf8_terminal() -> bool:
204
+ """Put curses in UTF-8 mode; under a C/POSIX locale it mangles block glyphs."""
205
+ try:
206
+ locale.setlocale(locale.LC_ALL, "")
207
+ except locale.Error:
208
+ pass
209
+ if "utf" in locale.nl_langinfo(locale.CODESET).lower():
210
+ return True
211
+ for name in ("C.UTF-8", "en_US.UTF-8", "UTF-8"):
212
+ try:
213
+ locale.setlocale(locale.LC_CTYPE, name)
214
+ return True
215
+ except locale.Error:
216
+ continue
217
+ return False
218
+
219
+
196
220
  def configure() -> int:
197
221
  model = model_from(load_config())
222
+ ascii_only = not utf8_terminal()
198
223
  try:
199
- save = curses.wrapper(_ui, model)
224
+ save = curses.wrapper(_ui, model, ascii_only)
200
225
  except curses.error as error:
201
226
  print(f"codex-statusline: needs an interactive terminal ({error}).")
202
227
  print(f"Edit {config_path()} instead.")
@@ -126,7 +126,6 @@ class LogReader:
126
126
  self._quotas = _empty_quotas()
127
127
  self._tokens: float | None = None
128
128
  self._usage: dict[str, float] | None = None
129
- self._window: float | None = None
130
129
 
131
130
  def _reset(self, path: str) -> None:
132
131
  self._path = path
@@ -138,7 +137,6 @@ class LogReader:
138
137
  self._quotas = _empty_quotas()
139
138
  self._tokens = None
140
139
  self._usage = None
141
- self._window = None
142
140
 
143
141
  def _consume_line(self, line: bytes) -> None:
144
142
  try:
@@ -162,9 +160,6 @@ class LogReader:
162
160
  usage = {key: total.get(key) for key in USAGE_KEYS}
163
161
  if all(_count(v) for v in usage.values()):
164
162
  self._usage = {key: float(v) for key, v in usage.items()}
165
- window = info.get("model_context_window") if isinstance(info, dict) else None
166
- if _count(window) and window > 0:
167
- self._window = float(window)
168
163
  # Native versions may keep rate_limits inside info; quota-only events
169
164
  # can instead carry rate_limits directly on payload (even if info is null).
170
165
  rate_limits = info.get("rate_limits") if isinstance(info, dict) else None
@@ -246,7 +241,6 @@ class LogReader:
246
241
  self._quotas = _empty_quotas()
247
242
  self._tokens = None
248
243
  self._usage = None
249
- self._window = None
250
244
  if stat.st_size > _MAX_READ_BYTES:
251
245
  # Start at a bounded tail boundary, discarding its possibly
252
246
  # incomplete first line. Current quota events remain
@@ -282,7 +276,6 @@ class LogReader:
282
276
  self._quotas = _empty_quotas()
283
277
  self._tokens = None
284
278
  self._usage = None
285
- self._window = None
286
279
  except OSError:
287
280
  # Avoid exporting filesystem details or turning transient I/O into
288
281
  # fabricated quota data; previously observed metadata remains.
@@ -291,7 +284,6 @@ class LogReader:
291
284
  "quotas": {name: dict(value) for name, value in self._quotas.items()},
292
285
  "tokens": self._tokens,
293
286
  "usage": dict(self._usage) if self._usage else None,
294
- "window": self._window,
295
287
  }
296
288
 
297
289
 
@@ -222,7 +222,8 @@ def launch(args):
222
222
  stream.write(f'set -g status-style "{THEMES[cfg["theme"]]}"\n')
223
223
  if cfg["line2"]: # second status row, filled from the @line2 option
224
224
  stream.write('set -g status 2\nset -g status-format[1] "#{@line2}"\n')
225
- base = [tmux, "-S", str(root / "tmux.sock"), "-f", str(conf)]
225
+ # -u: draw UTF-8 even when LANG/LC_* say otherwise (e.g. over ssh), or tmux drops the bars.
226
+ base = [tmux, "-u", "-S", str(root / "tmux.sock"), "-f", str(conf)]
226
227
  env = dict(os.environ)
227
228
  env.pop("TMUX", None)
228
229
  env.pop("TMUX_PANE", None)
@@ -279,7 +280,7 @@ def launch(args):
279
280
  state["cwd"] = bound.get("cwd") or config["cwd"]
280
281
  snapshot = reader.update(bound["transcript_path"])
281
282
  state["quotas"] = snapshot.get("quotas", {})
282
- for key in ("tokens", "usage", "window"):
283
+ for key in ("tokens", "usage"):
283
284
  state[key] = snapshot.get(key)
284
285
  state["quotas"] = merge_quotas(state["quotas"], others)
285
286
  state["update"] = latest.get("version")
@@ -32,9 +32,9 @@ _CONFIG_DEFAULTS = {
32
32
  "ascii": False,
33
33
  "warn_at": 20,
34
34
  "crit_at": 5,
35
- "line2": [],
35
+ "line2": ["usage"],
36
36
  }
37
- LINE2_SEGMENTS = ["pace", "usage"]
37
+ LINE2_SEGMENTS = ["usage", "pace"]
38
38
 
39
39
 
40
40
  def config_path() -> Path:
@@ -172,8 +172,10 @@ def _reset(reset_at: Any, now: float, weekly: bool = False) -> str:
172
172
  return "" # window already reset; next reset is unknown until Codex reports again
173
173
  try:
174
174
  moment = datetime.fromtimestamp(stamp)
175
- time = moment.strftime("%I:%M%p").lstrip("0").lower()
176
- return f"{moment.strftime('%a ' if weekly else '')}{time}"
175
+ # Built by hand: strftime %p/%a follow the locale (e.g. 午後, 土).
176
+ clock = f"{moment.hour % 12 or 12}:{moment.minute:02d}{'am' if moment.hour < 12 else 'pm'}"
177
+ day = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")[moment.weekday()]
178
+ return f"{day} {clock}" if weekly else clock
177
179
  except (OverflowError, OSError, ValueError):
178
180
  return "--"
179
181
 
@@ -242,24 +244,20 @@ def _pace(data: Any, minutes: int, now: float, weekly: bool) -> tuple[str, str]
242
244
  return f"{label} pace: runs out ~{_reset(empty_at, now, weekly)}", "warning"
243
245
 
244
246
 
245
- def _usage(state: dict) -> str | None:
247
+ def _usage(state: dict) -> str:
246
248
  usage = state.get("usage")
247
249
  if not isinstance(usage, dict):
248
- return None
250
+ return "in -- · out --" # before the first turn
249
251
  numbers = [
250
252
  _number(usage.get(k)) for k in ("input_tokens", "cached_input_tokens", "output_tokens")
251
253
  ]
252
254
  if any(n is None or n < 0 for n in numbers):
253
- return None
255
+ return "in -- · out --"
254
256
  given, cached, output = numbers
255
257
  text = f"in {_si(given)}"
256
258
  if given:
257
259
  text += f" · {round(100 * min(cached, given) / given)}% cached"
258
- text += f" · out {_si(output)}"
259
- window, context = _number(state.get("window")), _number(state.get("context_used"))
260
- if window and context is not None:
261
- text += f" · ctx {_si(window * context / 100)}/{_si(window)}"
262
- return text
260
+ return text + f" · out {_si(output)}"
263
261
 
264
262
 
265
263
  def render_line2(
@@ -270,12 +268,14 @@ def render_line2(
270
268
  ascii_only: bool = False,
271
269
  tmux: bool = True,
272
270
  ) -> str:
273
- """Optional second line: quota pace and token breakdown; drops parts from the end."""
271
+ """Second line: token breakdown, then quota pace; drops parts from the end."""
274
272
  width = max(0, int(width))
275
273
  state = state if isinstance(state, dict) else {}
276
274
  now = _number(state.get("now")) or datetime.now().timestamp()
277
275
  quotas = state.get("quotas") if isinstance(state.get("quotas"), dict) else {}
278
276
  parts: list[tuple[str, str]] = []
277
+ if "usage" in line2:
278
+ parts.append((_usage(state), "accent"))
279
279
  if "pace" in line2:
280
280
  for data, minutes, weekly in (
281
281
  (quotas.get("5h"), 300, False),
@@ -284,10 +284,6 @@ def render_line2(
284
284
  pace = _pace(data, minutes, now, weekly)
285
285
  if pace:
286
286
  parts.append(pace)
287
- if "usage" in line2:
288
- usage = _usage(state)
289
- if usage:
290
- parts.append((usage, "accent"))
291
287
  sep = " | "
292
288
  if ascii_only:
293
289
  parts = [(text.replace("·", "-"), role) for text, role in parts]
@@ -22,9 +22,10 @@ class ConfigureTests(unittest.TestCase):
22
22
  apply(m, 9, "left") # crit_at 5 -> 0
23
23
  apply(m, 9, "left") # clamps at 0
24
24
  apply(m, 10, "space") # update notice off
25
- apply(m, 5, "space") # line 2: usage on
25
+ apply(m, 4, "space") # line 2: usage (on by default) -> off
26
+ apply(m, 5, "space") # line 2: pace on
26
27
  cfg = to_config(m)
27
- self.assertEqual(cfg["line2"], ["usage"])
28
+ self.assertEqual(cfg["line2"], ["pace"])
28
29
  self.assertEqual(cfg["segments"], ["ctx", "week", "5h"])
29
30
  self.assertEqual((cfg["theme"], cfg["warn_at"], cfg["crit_at"]), ("dark", 15, 0))
30
31
  self.assertFalse(cfg["update_check"])
@@ -32,7 +32,6 @@ class Line2Tests(unittest.TestCase):
32
32
  state = {
33
33
  "now": NOW,
34
34
  "context_used": 50,
35
- "window": 258_400,
36
35
  "usage": {
37
36
  "input_tokens": 2_000_000,
38
37
  "cached_input_tokens": 1_900_000,
@@ -41,14 +40,16 @@ class Line2Tests(unittest.TestCase):
41
40
  "quotas": {"5h": quota(22, 3 * 3600)},
42
41
  }
43
42
  wide = render_line2(state, 200, ["pace", "usage"], tmux=False)
44
- self.assertIn("in 2.0M · 95% cached · out 66.0k · ctx 129.2k/258.4k", wide)
45
- narrow = render_line2(state, 40, ["pace", "usage"], tmux=False)
46
- self.assertIn("5h pace", narrow)
47
- self.assertNotIn("cached", narrow)
43
+ self.assertIn("in 2.0M · 95% cached · out 66.0k", wide)
44
+ self.assertLess(wide.index("cached"), wide.index("5h pace")) # usage first
45
+ narrow = render_line2(state, 40, ["usage", "pace"], tmux=False)
46
+ self.assertIn("cached", narrow)
47
+ self.assertNotIn("pace", narrow) # pace drops first
48
+ self.assertIn("in -- · out --", render_line2({}, 80, ["usage"], tmux=False))
48
49
  self.assertTrue(render_line2(state, 200, ["usage"], tmux=False, ascii_only=True).isascii())
49
50
  self.assertEqual(render_line2(state, 200, [], tmux=False), "")
50
51
 
51
- def test_reader_keeps_breakdown_and_window(self):
52
+ def test_reader_keeps_token_breakdown(self):
52
53
  info = {
53
54
  "total_token_usage": {
54
55
  "input_tokens": 10,
@@ -56,7 +57,6 @@ class Line2Tests(unittest.TestCase):
56
57
  "output_tokens": 2,
57
58
  "total_tokens": 12,
58
59
  },
59
- "model_context_window": 258_400,
60
60
  }
61
61
  event = {
62
62
  "type": "event_msg",
@@ -70,13 +70,14 @@ class Line2Tests(unittest.TestCase):
70
70
  self.assertEqual(
71
71
  snap["usage"], {"input_tokens": 10.0, "cached_input_tokens": 4.0, "output_tokens": 2.0}
72
72
  )
73
- self.assertEqual(snap["window"], 258_400.0)
74
73
 
75
74
  def test_config_line2_validated(self):
76
75
  with tempfile.TemporaryDirectory() as tmp:
77
76
  path = Path(tmp) / "c.toml"
78
77
  path.write_text('line2 = ["pace", "usage"]\n')
79
78
  self.assertEqual(load_config(path)["line2"], ["pace", "usage"])
79
+ path.write_text("")
80
+ self.assertEqual(load_config(path)["line2"], ["usage"]) # usage on by default
80
81
  path.write_text('line2 = ["bogus"]\n')
81
82
  with unittest.mock.patch("sys.stderr"):
82
- self.assertEqual(load_config(path)["line2"], [])
83
+ self.assertEqual(load_config(path)["line2"], ["usage"]) # invalid -> default
@@ -237,3 +237,24 @@ class ConfigTests(unittest.TestCase):
237
237
 
238
238
  if __name__ == "__main__":
239
239
  unittest.main()
240
+
241
+
242
+ class ClockTests(unittest.TestCase):
243
+ def test_reset_time_ignores_locale(self):
244
+ import locale
245
+ from datetime import datetime
246
+
247
+ from src.render import _reset
248
+
249
+ stamp = datetime(2026, 10, 3, 18, 5).timestamp() # a Saturday, 6:05pm
250
+ previous = locale.setlocale(locale.LC_TIME)
251
+ try:
252
+ for name in ("ja_JP.UTF-8", "de_DE.UTF-8", "C"):
253
+ try:
254
+ locale.setlocale(locale.LC_TIME, name)
255
+ except locale.Error:
256
+ continue
257
+ self.assertEqual(_reset(stamp, 0), "6:05pm")
258
+ self.assertEqual(_reset(stamp, 0, weekly=True), "Sat 6:05pm")
259
+ finally:
260
+ locale.setlocale(locale.LC_TIME, previous)