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.
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/PKG-INFO +1 -1
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/codex_statusline.egg-info/PKG-INFO +1 -1
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/pyproject.toml +1 -1
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/__init__.py +1 -1
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/configure.py +32 -7
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/data.py +0 -8
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/launcher.py +3 -2
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/render.py +13 -17
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_configure.py +3 -2
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_line2.py +10 -9
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_render.py +21 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/LICENSE +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/README.md +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/codex_statusline.egg-info/SOURCES.txt +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/codex_statusline.egg-info/dependency_links.txt +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/codex_statusline.egg-info/entry_points.txt +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/codex_statusline.egg-info/top_level.txt +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/setup.cfg +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/__main__.py +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/cli.py +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/doctor.py +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/entry.py +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/hook.py +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/manage.py +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/src/update.py +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_auto_shells.py +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_data.py +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_doctor.py +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_fish.py +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_management.py +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_startup.py +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_tmux.py +0 -0
- {codex_statusline-0.4.0 → codex_statusline-0.4.2}/tests/test_update.py +0 -0
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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"
|
|
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 = ["
|
|
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
|
-
|
|
176
|
-
|
|
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
|
|
247
|
+
def _usage(state: dict) -> str:
|
|
246
248
|
usage = state.get("usage")
|
|
247
249
|
if not isinstance(usage, dict):
|
|
248
|
-
return
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
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,
|
|
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"], ["
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
self.
|
|
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
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codex_statusline-0.4.0 → codex_statusline-0.4.2}/codex_statusline.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{codex_statusline-0.4.0 → codex_statusline-0.4.2}/codex_statusline.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|