codex-statusline 0.1.2__tar.gz → 0.2.1__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 (30) hide show
  1. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/PKG-INFO +19 -3
  2. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/README.md +18 -2
  3. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/codex_statusline.egg-info/PKG-INFO +19 -3
  4. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/codex_statusline.egg-info/SOURCES.txt +2 -0
  5. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/pyproject.toml +1 -1
  6. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/__init__.py +1 -1
  7. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/cli.py +7 -1
  8. codex_statusline-0.2.1/src/configure.py +174 -0
  9. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/data.py +18 -5
  10. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/launcher.py +9 -5
  11. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/manage.py +78 -42
  12. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/render.py +6 -2
  13. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/update.py +5 -3
  14. codex_statusline-0.2.1/tests/test_configure.py +42 -0
  15. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/tests/test_data.py +4 -0
  16. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/tests/test_management.py +15 -0
  17. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/tests/test_startup.py +27 -3
  18. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/tests/test_update.py +4 -2
  19. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/LICENSE +0 -0
  20. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/codex_statusline.egg-info/dependency_links.txt +0 -0
  21. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/codex_statusline.egg-info/entry_points.txt +0 -0
  22. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/codex_statusline.egg-info/top_level.txt +0 -0
  23. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/setup.cfg +0 -0
  24. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/__main__.py +0 -0
  25. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/entry.py +0 -0
  26. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/src/hook.py +0 -0
  27. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/tests/test_auto_shells.py +0 -0
  28. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/tests/test_fish.py +0 -0
  29. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/tests/test_render.py +0 -0
  30. {codex_statusline-0.1.2 → codex_statusline-0.2.1}/tests/test_tmux.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-statusline
3
- Version: 0.1.2
3
+ Version: 0.2.1
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
@@ -73,6 +73,7 @@ codex # same as always, now with a status bar
73
73
  codex resume --last
74
74
  codex exec 'task' # non-interactive: passed straight through, no bar
75
75
 
76
+ codex-statusline config # pick segments, theme and colors interactively
76
77
  codex-statusline update # upgrade, whichever way you installed it
77
78
  codex-statusline doctor # check versions and dependencies
78
79
  codex-statusline preview --width 80 # try it without launching Codex
@@ -83,7 +84,22 @@ Uninstall removes only what this tool added and still matches exactly. If you ed
83
84
 
84
85
  ## Configuration
85
86
 
86
- Optional. Create `~/.config/codex-statusline/config.toml`:
87
+ Run `codex-statusline config` to set everything up in an interactive screen with a live preview: toggle and reorder segments, switch theme, set the warning thresholds, then press `s` to save.
88
+
89
+ ```text
90
+ [x] ctx context used
91
+ [x] 5h 5-hour quota
92
+ [x] week weekly quota
93
+ [x] tokens session tokens
94
+ > Theme < auto >
95
+ Quota yellow at < 20% >
96
+ Quota red at < 5% >
97
+
98
+ Preview (sample numbers):
99
+ CTX USED ███░░░░░ 35% | 5h ██░░░░░░░░ 18% 3:46pm | week ██████░░░░ 62% Sat 1:46pm | tok 1.2M
100
+ ```
101
+
102
+ It writes `~/.config/codex-statusline/config.toml`, which you can also edit by hand:
87
103
 
88
104
  ```toml
89
105
  segments = ["ctx", "5h", "week", "tokens"] # pick and reorder
@@ -102,7 +118,7 @@ Invalid values are reported and fall back to defaults.
102
118
 
103
119
  ## FAQ
104
120
 
105
- **How do I scroll or copy?** It runs inside tmux: press `Ctrl-B` then `[` to scroll and copy. The mouse wheel may behave differently from bare Codex.
121
+ **How do I scroll or copy?** Exactly as in plain Codex: the mouse wheel scrolls the conversation, and text selection works the same way.
106
122
 
107
123
  **Windows?** Not supported (tmux).
108
124
 
@@ -52,6 +52,7 @@ codex # same as always, now with a status bar
52
52
  codex resume --last
53
53
  codex exec 'task' # non-interactive: passed straight through, no bar
54
54
 
55
+ codex-statusline config # pick segments, theme and colors interactively
55
56
  codex-statusline update # upgrade, whichever way you installed it
56
57
  codex-statusline doctor # check versions and dependencies
57
58
  codex-statusline preview --width 80 # try it without launching Codex
@@ -62,7 +63,22 @@ Uninstall removes only what this tool added and still matches exactly. If you ed
62
63
 
63
64
  ## Configuration
64
65
 
65
- Optional. Create `~/.config/codex-statusline/config.toml`:
66
+ Run `codex-statusline config` to set everything up in an interactive screen with a live preview: toggle and reorder segments, switch theme, set the warning thresholds, then press `s` to save.
67
+
68
+ ```text
69
+ [x] ctx context used
70
+ [x] 5h 5-hour quota
71
+ [x] week weekly quota
72
+ [x] tokens session tokens
73
+ > Theme < auto >
74
+ Quota yellow at < 20% >
75
+ Quota red at < 5% >
76
+
77
+ Preview (sample numbers):
78
+ CTX USED ███░░░░░ 35% | 5h ██░░░░░░░░ 18% 3:46pm | week ██████░░░░ 62% Sat 1:46pm | tok 1.2M
79
+ ```
80
+
81
+ It writes `~/.config/codex-statusline/config.toml`, which you can also edit by hand:
66
82
 
67
83
  ```toml
68
84
  segments = ["ctx", "5h", "week", "tokens"] # pick and reorder
@@ -81,7 +97,7 @@ Invalid values are reported and fall back to defaults.
81
97
 
82
98
  ## FAQ
83
99
 
84
- **How do I scroll or copy?** It runs inside tmux: press `Ctrl-B` then `[` to scroll and copy. The mouse wheel may behave differently from bare Codex.
100
+ **How do I scroll or copy?** Exactly as in plain Codex: the mouse wheel scrolls the conversation, and text selection works the same way.
85
101
 
86
102
  **Windows?** Not supported (tmux).
87
103
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-statusline
3
- Version: 0.1.2
3
+ Version: 0.2.1
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
@@ -73,6 +73,7 @@ codex # same as always, now with a status bar
73
73
  codex resume --last
74
74
  codex exec 'task' # non-interactive: passed straight through, no bar
75
75
 
76
+ codex-statusline config # pick segments, theme and colors interactively
76
77
  codex-statusline update # upgrade, whichever way you installed it
77
78
  codex-statusline doctor # check versions and dependencies
78
79
  codex-statusline preview --width 80 # try it without launching Codex
@@ -83,7 +84,22 @@ Uninstall removes only what this tool added and still matches exactly. If you ed
83
84
 
84
85
  ## Configuration
85
86
 
86
- Optional. Create `~/.config/codex-statusline/config.toml`:
87
+ Run `codex-statusline config` to set everything up in an interactive screen with a live preview: toggle and reorder segments, switch theme, set the warning thresholds, then press `s` to save.
88
+
89
+ ```text
90
+ [x] ctx context used
91
+ [x] 5h 5-hour quota
92
+ [x] week weekly quota
93
+ [x] tokens session tokens
94
+ > Theme < auto >
95
+ Quota yellow at < 20% >
96
+ Quota red at < 5% >
97
+
98
+ Preview (sample numbers):
99
+ CTX USED ███░░░░░ 35% | 5h ██░░░░░░░░ 18% 3:46pm | week ██████░░░░ 62% Sat 1:46pm | tok 1.2M
100
+ ```
101
+
102
+ It writes `~/.config/codex-statusline/config.toml`, which you can also edit by hand:
87
103
 
88
104
  ```toml
89
105
  segments = ["ctx", "5h", "week", "tokens"] # pick and reorder
@@ -102,7 +118,7 @@ Invalid values are reported and fall back to defaults.
102
118
 
103
119
  ## FAQ
104
120
 
105
- **How do I scroll or copy?** It runs inside tmux: press `Ctrl-B` then `[` to scroll and copy. The mouse wheel may behave differently from bare Codex.
121
+ **How do I scroll or copy?** Exactly as in plain Codex: the mouse wheel scrolls the conversation, and text selection works the same way.
106
122
 
107
123
  **Windows?** Not supported (tmux).
108
124
 
@@ -9,6 +9,7 @@ codex_statusline.egg-info/top_level.txt
9
9
  src/__init__.py
10
10
  src/__main__.py
11
11
  src/cli.py
12
+ src/configure.py
12
13
  src/data.py
13
14
  src/entry.py
14
15
  src/hook.py
@@ -17,6 +18,7 @@ src/manage.py
17
18
  src/render.py
18
19
  src/update.py
19
20
  tests/test_auto_shells.py
21
+ tests/test_configure.py
20
22
  tests/test_data.py
21
23
  tests/test_fish.py
22
24
  tests/test_management.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codex-statusline"
3
- version = "0.1.2"
3
+ version = "0.2.1"
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.1.2"
3
+ __version__ = "0.2.1"
@@ -42,10 +42,12 @@ def main():
42
42
  s.add_argument(
43
43
  "--yes",
44
44
  action="store_true",
45
- help="approve printed diff without interactive prompt",
45
+ help="apply without asking",
46
46
  )
47
+ s.add_argument("--diff", action="store_true", help="show the exact file changes")
47
48
  s.add_argument("--dry-run", action="store_true")
48
49
  sub.add_parser("doctor")
50
+ sub.add_parser("config", help="choose segments, theme and thresholds interactively")
49
51
  sub.add_parser("update", help="upgrade codex-statusline the way it was installed")
50
52
  p = sub.add_parser("preview")
51
53
  p.add_argument("--width", type=int, default=120)
@@ -58,6 +60,10 @@ def main():
58
60
  from .manage import installation
59
61
 
60
62
  return installation(ns, ns.action == "uninstall")
63
+ if ns.action == "config":
64
+ from .configure import configure
65
+
66
+ return configure()
61
67
  if ns.action == "update":
62
68
  from .update import update
63
69
 
@@ -0,0 +1,174 @@
1
+ """`codex-statusline config`: pick segments, theme and thresholds with a live preview."""
2
+
3
+ import curses
4
+ import time
5
+
6
+ from .render import DEFAULT_SEGMENTS, THEMES, config_path, load_config, render
7
+
8
+ SEGMENT_NAMES = {
9
+ "ctx": "context used",
10
+ "5h": "5-hour quota",
11
+ "week": "weekly quota",
12
+ "tokens": "session tokens",
13
+ }
14
+ SETTINGS = ["theme", "ascii", "warn_at", "crit_at", "update_check"]
15
+ LABELS = {
16
+ "theme": "Theme",
17
+ "ascii": "ASCII only",
18
+ "warn_at": "Quota yellow at",
19
+ "crit_at": "Quota red at",
20
+ "update_check": "Update notice",
21
+ }
22
+ HELP = "↑↓ move space toggle ←→ change J/K reorder s save q quit"
23
+
24
+
25
+ def model_from(cfg: dict) -> dict:
26
+ enabled = [name for name in cfg["segments"] if name in SEGMENT_NAMES]
27
+ order = enabled + [name for name in DEFAULT_SEGMENTS if name not in enabled]
28
+ return {**{key: cfg[key] for key in SETTINGS}, "order": order, "enabled": set(enabled)}
29
+
30
+
31
+ def to_config(model: dict) -> dict:
32
+ segments = [name for name in model["order"] if name in model["enabled"]]
33
+ return {"segments": segments, **{key: model[key] for key in SETTINGS}}
34
+
35
+
36
+ def dump_toml(cfg: dict) -> str:
37
+ def value(v):
38
+ if isinstance(v, bool):
39
+ return "true" if v else "false"
40
+ if isinstance(v, list):
41
+ return "[" + ", ".join(f'"{x}"' for x in v) + "]"
42
+ return f'"{v}"' if isinstance(v, str) else str(v)
43
+
44
+ return "".join(f"{key} = {value(v)}\n" for key, v in cfg.items())
45
+
46
+
47
+ def row_count(model: dict) -> int:
48
+ return len(model["order"]) + len(SETTINGS)
49
+
50
+
51
+ def apply(model: dict, row: int, key: str) -> int:
52
+ """Apply one key press to the row under the cursor; returns the new cursor row."""
53
+ rows = row_count(model)
54
+ if key in ("up", "k"):
55
+ return (row - 1) % rows
56
+ if key in ("down", "j"):
57
+ return (row + 1) % rows
58
+ order = model["order"]
59
+ if row < len(order):
60
+ name = order[row]
61
+ if key == "space":
62
+ # Keep at least one segment on, or the bar would be empty.
63
+ if name not in model["enabled"]:
64
+ model["enabled"].add(name)
65
+ elif len(model["enabled"]) > 1:
66
+ model["enabled"].discard(name)
67
+ elif key in ("K", "J"):
68
+ target = row - 1 if key == "K" else row + 1
69
+ if 0 <= target < len(order):
70
+ order[row], order[target] = order[target], order[row]
71
+ return target
72
+ return row
73
+ setting = SETTINGS[row - len(order)]
74
+ current = model[setting]
75
+ step = {"left": -1, "right": 1}.get(key, 0)
76
+ if isinstance(current, bool):
77
+ if key in ("space", "left", "right"):
78
+ model[setting] = not current
79
+ elif setting == "theme" and (step or key == "space"):
80
+ names = list(THEMES)
81
+ model[setting] = names[(names.index(current) + (step or 1)) % len(names)]
82
+ elif step:
83
+ model[setting] = max(0, min(100, current + 5 * step))
84
+ return row
85
+
86
+
87
+ def rows_text(model: dict) -> list[str]:
88
+ lines = []
89
+ for name in model["order"]:
90
+ mark = "x" if name in model["enabled"] else " "
91
+ lines.append(f"[{mark}] {name:<7} {SEGMENT_NAMES[name]}")
92
+ for setting in SETTINGS:
93
+ value = model[setting]
94
+ if isinstance(value, bool):
95
+ shown = "[x]" if value else "[ ]"
96
+ elif setting == "theme":
97
+ shown = f"< {value} >"
98
+ else:
99
+ shown = f"< {value}% >"
100
+ lines.append(f"{LABELS[setting]:<16} {shown}")
101
+ return lines
102
+
103
+
104
+ def sample_state() -> dict:
105
+ now = time.time()
106
+ return {
107
+ "context_used": 35,
108
+ "tokens": 1_234_567,
109
+ "quotas": {
110
+ "5h": {"remaining": 18, "reset_at": now + 7200, "observed_at": now},
111
+ "weekly": {"remaining": 62, "reset_at": now + 172800, "observed_at": now},
112
+ },
113
+ }
114
+
115
+
116
+ KEYS = {
117
+ curses.KEY_UP: "up",
118
+ curses.KEY_DOWN: "down",
119
+ curses.KEY_LEFT: "left",
120
+ curses.KEY_RIGHT: "right",
121
+ ord(" "): "space",
122
+ }
123
+
124
+
125
+ def _ui(screen, model: dict) -> bool:
126
+ curses.curs_set(0)
127
+ row, state = 0, sample_state()
128
+ while True:
129
+ screen.erase()
130
+ height, width = screen.getmaxyx()
131
+ cfg = to_config(model)
132
+ preview = render(
133
+ state,
134
+ max(1, width - 4),
135
+ tmux=False,
136
+ ascii_only=cfg["ascii"],
137
+ segments=cfg["segments"],
138
+ warn_at=cfg["warn_at"],
139
+ crit_at=cfg["crit_at"],
140
+ )
141
+ lines = ["codex-statusline config", HELP, ""]
142
+ lines += [("> " if i == row else " ") + text for i, text in enumerate(rows_text(model))]
143
+ lines += ["", "Preview (sample numbers):", " " + preview, "", f"Saves to {config_path()}"]
144
+ for y, text in enumerate(lines[: height - 1]):
145
+ highlight = curses.A_REVERSE if y - 3 == row else curses.A_NORMAL
146
+ try:
147
+ screen.addnstr(y, 0, text, width - 1, highlight)
148
+ except curses.error:
149
+ pass # tiny terminal: draw what fits
150
+ key = screen.getch()
151
+ name = KEYS.get(key, chr(key) if 0 <= key < 256 else "")
152
+ if name in ("q", "\x1b"):
153
+ return False
154
+ if name in ("s", "\n"):
155
+ return True
156
+ row = apply(model, row, name)
157
+
158
+
159
+ def configure() -> int:
160
+ model = model_from(load_config())
161
+ try:
162
+ save = curses.wrapper(_ui, model)
163
+ except curses.error as error:
164
+ print(f"codex-statusline: needs an interactive terminal ({error}).")
165
+ print(f"Edit {config_path()} instead.")
166
+ return 1
167
+ if not save:
168
+ print("No changes saved.")
169
+ return 0
170
+ path = config_path()
171
+ path.parent.mkdir(parents=True, exist_ok=True)
172
+ path.write_text(dump_toml(to_config(model)))
173
+ print(f"Saved {path}. Takes effect the next time you start codex.")
174
+ return 0
@@ -284,8 +284,15 @@ def merge_quotas(*snapshots: dict[str, Any]) -> dict[str, Any]:
284
284
  return merged
285
285
 
286
286
 
287
- def newest_quotas(sessions: Path, readers: dict[str, LogReader], limit: int = 3) -> dict[str, Any]:
288
- """Freshest quotas across the most recently written local session logs."""
287
+ def sessions_dir() -> Path:
288
+ return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") / "sessions"
289
+
290
+
291
+ def newest_quotas(sessions: Path, readers: dict[str, LogReader], limit: int = 10) -> dict[str, Any]:
292
+ """Freshest quotas from local session logs, newest first, stopping once both windows are seen.
293
+
294
+ Sessions opened without a single turn carry no quota, so look past them.
295
+ """
289
296
  found = []
290
297
  try:
291
298
  for path in sessions.glob("*/*/*/*.jsonl"):
@@ -295,7 +302,13 @@ def newest_quotas(sessions: Path, readers: dict[str, LogReader], limit: int = 3)
295
302
  continue
296
303
  except OSError:
297
304
  return {}
298
- recent = [path for _, path in sorted(found, reverse=True)[:limit]]
299
- for stale in set(readers) - set(recent):
305
+ merged: dict[str, Any] = {}
306
+ scanned = []
307
+ for _, path in sorted(found, reverse=True)[:limit]:
308
+ scanned.append(path)
309
+ merged = merge_quotas(merged, readers.setdefault(path, LogReader()).update(path)["quotas"])
310
+ if {"5h", "weekly"} <= merged.keys():
311
+ break
312
+ for stale in set(readers) - set(scanned):
300
313
  del readers[stale]
301
- return merge_quotas(*(readers.setdefault(p, LogReader()).update(p)["quotas"] for p in recent))
314
+ return merged
@@ -158,6 +158,7 @@ def branch(cwd):
158
158
 
159
159
  def placeholder(width=80):
160
160
  """Use the normal layout before telemetry arrives or while it is unavailable."""
161
+ from .data import newest_quotas, sessions_dir
161
162
  from .render import load_config, render
162
163
 
163
164
  try:
@@ -165,7 +166,11 @@ def placeholder(width=80):
165
166
  except (TypeError, ValueError):
166
167
  width = 80
167
168
  cfg = load_config()
168
- return render({}, width, theme=cfg["theme"], ascii_only=cfg["ascii"], segments=cfg["segments"])
169
+ # Quota is account-wide, so even the very first frame can show it.
170
+ state = {"quotas": newest_quotas(sessions_dir(), {})}
171
+ return render(
172
+ state, width, theme=cfg["theme"], ascii_only=cfg["ascii"], segments=cfg["segments"]
173
+ )
169
174
 
170
175
 
171
176
  def launch(args):
@@ -210,7 +215,7 @@ def launch(args):
210
215
  'set -g status-left ""\n'
211
216
  "set -g allow-rename off\n"
212
217
  "set -g set-titles off\n"
213
- "set -g mouse off\n"
218
+ "set -g mouse on\n"
214
219
  "set -g history-limit 10000\n"
215
220
  "set -g remain-on-exit on\n"
216
221
  "set -g exit-empty on\n"
@@ -236,7 +241,7 @@ def launch(args):
236
241
  )
237
242
 
238
243
  def monitor():
239
- from .data import LogReader, merge_quotas, newest_quotas, parse_title
244
+ from .data import LogReader, merge_quotas, newest_quotas, parse_title, sessions_dir
240
245
  from .render import load_config, render
241
246
 
242
247
  cfg = load_config()
@@ -248,8 +253,7 @@ def launch(args):
248
253
  threading.Thread(
249
254
  target=lambda: latest.update(version=newer_version()), daemon=True
250
255
  ).start()
251
- codex_home = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
252
- sessions, readers, others, scan_at = codex_home / "sessions", {}, {}, 0.0
256
+ sessions, readers, others, scan_at = sessions_dir(), {}, {}, 0.0
253
257
  last_branch, branch_at = None, 0
254
258
  last_line = None
255
259
  while not stop.is_set():
@@ -16,6 +16,7 @@ from .launcher import ENTRY
16
16
 
17
17
  BEGIN = "# >>> codex-statusline >>>"
18
18
  END = "# <<< codex-statusline <<<"
19
+ STATUS_MESSAGE = "codex-statusline binding"
19
20
 
20
21
 
21
22
  def detected_shell():
@@ -96,7 +97,7 @@ def hook_group():
96
97
  "type": "command",
97
98
  "command": shlex.join([*base_command(), "hook"]),
98
99
  "timeout": 2,
99
- "statusMessage": "codex-statusline binding",
100
+ "statusMessage": STATUS_MESSAGE,
100
101
  }
101
102
  ]
102
103
  }
@@ -167,14 +168,45 @@ def modify_hook(raw, group, add):
167
168
  return json.dumps(obj, ensure_ascii=False, indent=2) + "\n"
168
169
 
169
170
 
171
+ def summary(changed, home):
172
+ """One line per file: what is added or removed, in words rather than a diff."""
173
+ lines = ["codex-statusline will:"]
174
+ for p, old, new in changed:
175
+ what = "the SessionStart hook" if p.name == "hooks.json" else "the codex shell function"
176
+ if p.name == "hooks.json": # JSON is re-serialized, so compare our marker instead
177
+ had, has = (STATUS_MESSAGE in t for t in (old, new))
178
+ sign, verb = (
179
+ ("+", "add") if has and not had else ("-", "remove") if had else ("~", "update")
180
+ )
181
+ elif old in new:
182
+ sign, verb = "+", "add"
183
+ elif new in old:
184
+ sign, verb = "-", "remove"
185
+ else:
186
+ sign, verb = "~", "update"
187
+ lines.append(f" {sign} {tilde(p, home):<44} {verb} {what}")
188
+ return "\n".join(lines)
189
+
190
+
191
+ def tilde(p, home):
192
+ try:
193
+ return "~/" + str(Path(p).relative_to(home))
194
+ except ValueError:
195
+ return str(p)
196
+
197
+
170
198
  def installation(args, uninstall=False):
171
199
  chome, shells, manifest_path = paths(args.home, args.shell)
172
- changes = []
173
200
  previous = json.loads(text(manifest_path) or "{}")
174
201
  if uninstall and not previous:
175
202
  print("Nothing installed by codex-statusline.")
176
203
  return 0
177
- if uninstall:
204
+ pending = {} # path -> new text; several steps may edit the same file
205
+
206
+ def current(p):
207
+ return pending.get(p, text(p))
208
+
209
+ def remove_previous():
178
210
  # Manifest defines owned paths; never use current shell choice to guess.
179
211
  expected_home, allowed_shells, _ = paths(args.home, "all")
180
212
  if previous.get("hooks_path") != str(expected_home / "hooks.json") or any(
@@ -183,19 +215,20 @@ def installation(args, uninstall=False):
183
215
  or not i["block"].endswith(END + "\n")
184
216
  for i in previous.get("shells", [])
185
217
  ):
186
- raise ValueError("Manifest paths/blocks do not match this home; refusing uninstall.")
187
- group = previous["hook_group"]
188
- hp = Path(previous["hooks_path"])
189
- old = text(hp)
190
- new = modify_hook(old, group, False)
191
- changes.append((hp, old, new))
218
+ raise ValueError("Manifest paths/blocks do not match this home; refusing to remove it.")
219
+ php = Path(previous["hooks_path"])
220
+ pending[php] = modify_hook(current(php), previous["hook_group"], False)
192
221
  for item in previous["shells"]:
193
222
  p = Path(item["path"])
194
- old = text(p)
195
- owned = item["block"]
196
- if owned not in old and BEGIN in old:
223
+ old = current(p)
224
+ if item["block"] not in old and BEGIN in old:
197
225
  raise ValueError(f"Owned shell block was edited; remove it manually: {p}")
198
- changes.append((p, old, old.replace(owned, "", 1)))
226
+ pending[p] = old.replace(item["block"], "", 1)
227
+
228
+ migrating = False
229
+ if uninstall:
230
+ group, hp = previous["hook_group"], Path(previous["hooks_path"])
231
+ remove_previous()
199
232
  else:
200
233
  cfg = tomllib.loads(text(chome / "config.toml"))
201
234
  hook_config = cfg.get("hooks", {})
@@ -206,40 +239,37 @@ def installation(args, uninstall=False):
206
239
  )
207
240
  group = hook_group()
208
241
  hp = chome / "hooks.json"
209
- old = text(hp)
210
- changes.append((hp, old, modify_hook(old, group, True)))
242
+ # An older install from another path (e.g. a git checkout before uv/pipx):
243
+ # swap it out in the same reviewed diff instead of asking for an uninstall first.
244
+ migrating = bool(previous) and (
245
+ previous.get("hook_group") != group or previous.get("hooks_path") != str(hp)
246
+ )
247
+ if migrating:
248
+ print("Replacing a previous codex-statusline installation from another path.")
249
+ remove_previous()
250
+ pending[hp] = modify_hook(current(hp), group, True)
211
251
  for p in shells:
212
252
  block = shell_block("fish" if p.suffix == ".fish" else "zsh")
213
- old = text(p)
253
+ old = current(p)
214
254
  if BEGIN in old and block not in old:
215
255
  raise ValueError(
216
256
  f"A different/edited codex-statusline block exists in {p}; "
217
257
  "uninstall that version first."
218
258
  )
219
- changes.append((p, old, old if block in old else old + block))
220
- if previous and (
221
- previous.get("hook_group") != group or previous.get("hooks_path") != str(hp)
222
- ):
223
- raise ValueError(
224
- "Existing installation uses another project/hook path. Uninstall it first."
225
- )
259
+ pending[p] = old if block in old else old + block
226
260
  allowed = paths(args.home, "all")[1]
227
261
  if any(Path(x["path"]) not in allowed for x in previous.get("shells", [])):
228
262
  raise ValueError("Existing manifest contains unexpected shell paths.")
263
+ changes = [(p, text(p), new) for p, new in pending.items()]
229
264
  changed = [(p, old, new) for p, old, new in changes if old != new]
230
- for p, old, new in changed:
231
- print(
232
- "".join(
233
- difflib.unified_diff(
234
- old.splitlines(True),
235
- new.splitlines(True),
236
- fromfile=str(p),
237
- tofile=str(p),
238
- n=0,
239
- )
240
- ),
241
- end="",
242
- )
265
+ if changed:
266
+ print(summary(changed, Path(args.home).expanduser()))
267
+ if changed and (getattr(args, "diff", False) or args.dry_run):
268
+ for p, old, new in changed:
269
+ diff = difflib.unified_diff(
270
+ old.splitlines(True), new.splitlines(True), str(p), str(p), n=0
271
+ )
272
+ print("".join(diff), end="")
243
273
  if args.dry_run:
244
274
  print("Dry run: no files written.")
245
275
  return 0
@@ -252,7 +282,12 @@ def installation(args, uninstall=False):
252
282
  print("Already installed; no configuration changes.")
253
283
  return 0
254
284
  if not args.yes:
255
- if not sys.stdin.isatty() or input("Apply these changes? [y/N] ").strip().lower() != "y":
285
+ hint = "" if getattr(args, "diff", False) else " (--diff shows the exact changes)"
286
+ if not sys.stdin.isatty() or input(f"Apply?{hint} [Y/n] ").strip().lower() not in (
287
+ "",
288
+ "y",
289
+ "yes",
290
+ ):
256
291
  print("No files changed.")
257
292
  return 0
258
293
  # Refuse symlinks for edited files. User must explicitly manage those targets.
@@ -284,7 +319,8 @@ def installation(args, uninstall=False):
284
319
  if uninstall:
285
320
  manifest_path.unlink(missing_ok=True)
286
321
  else:
287
- owned = {item["path"]: item for item in previous.get("shells", [])}
322
+ kept = [] if migrating else previous.get("shells", [])
323
+ owned = {item["path"]: item for item in kept}
288
324
  for p in shells:
289
325
  owned[str(p)] = {
290
326
  "path": str(p),
@@ -306,10 +342,10 @@ def installation(args, uninstall=False):
306
342
  p.unlink(missing_ok=True)
307
343
  raise
308
344
  print(
309
- "Uninstalled. Open a fresh terminal (existing shell functions remain until then)."
345
+ "Uninstalled. Open a new terminal to finish."
310
346
  if uninstall
311
- else "Installed. Open a fresh terminal, run codex, and review/trust "
312
- "the codex-statusline hook in the official UI."
347
+ else "Installed. Open a new terminal and run codex; trust the "
348
+ f"'{STATUS_MESSAGE}' hook when Codex asks."
313
349
  )
314
- print(f"Configuration change backup: {backup}")
350
+ print(f"Backup: {tilde(backup, Path(args.home).expanduser())}")
315
351
  return 0
@@ -35,11 +35,15 @@ _CONFIG_DEFAULTS = {
35
35
  }
36
36
 
37
37
 
38
+ def config_path() -> Path:
39
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
40
+ return Path(base) / "codex-statusline" / "config.toml"
41
+
42
+
38
43
  def load_config(path: str | os.PathLike | None = None) -> dict[str, Any]:
39
44
  """Read $XDG_CONFIG_HOME/codex-statusline/config.toml; invalid values fall back to defaults."""
40
45
  if path is None:
41
- base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
42
- path = Path(base) / "codex-statusline" / "config.toml"
46
+ path = config_path()
43
47
  config = dict(_CONFIG_DEFAULTS)
44
48
  try:
45
49
  raw = tomllib.loads(Path(path).read_text())
@@ -38,11 +38,13 @@ def newer_version(timeout: float = 2) -> str | None:
38
38
 
39
39
 
40
40
  def upgrade_command(prefix: str = sys.prefix, package: Path = PACKAGE) -> list[str]:
41
- # uv and pipx each leave a marker file in the tool's environment.
41
+ # uv and pipx each leave a marker file in the tool's environment. Install the latest
42
+ # release from PyPI explicitly: `upgrade` keeps the original source, so a tool first
43
+ # installed from a local checkout would otherwise never leave that version.
42
44
  if (Path(prefix) / "uv-receipt.toml").exists():
43
- return ["uv", "tool", "upgrade", "codex-statusline"]
45
+ return ["uv", "tool", "install", "codex-statusline@latest"]
44
46
  if (Path(prefix) / "pipx_metadata.json").exists():
45
- return ["pipx", "upgrade", "codex-statusline"]
47
+ return ["pipx", "install", "--force", "codex-statusline"]
46
48
  if (package.parent / ".git").exists():
47
49
  return ["git", "-C", str(package.parent), "pull", "--ff-only"]
48
50
  return [sys.executable, "-m", "pip", "install", "--upgrade", "codex-statusline"]
@@ -0,0 +1,42 @@
1
+ import tempfile
2
+ import unittest
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+
6
+ from src.configure import apply, dump_toml, model_from, rows_text, to_config
7
+ from src.render import load_config
8
+
9
+
10
+ class ConfigureTests(unittest.TestCase):
11
+ def setUp(self):
12
+ with patch.dict("os.environ", {}, clear=True):
13
+ self.model = model_from(load_config("/nonexistent/config.toml"))
14
+
15
+ def test_toggle_reorder_and_adjust(self):
16
+ m = self.model
17
+ row = apply(m, 3, "space") # turn tokens off
18
+ row = apply(m, 1, "J") # move 5h below week
19
+ self.assertEqual(row, 2)
20
+ apply(m, 4, "right") # theme auto -> dark
21
+ apply(m, 6, "left") # warn_at 20 -> 15
22
+ apply(m, 7, "left") # crit_at 5 -> 0
23
+ apply(m, 7, "left") # clamps at 0
24
+ apply(m, 8, "space") # update notice off
25
+ cfg = to_config(m)
26
+ self.assertEqual(cfg["segments"], ["ctx", "week", "5h"])
27
+ self.assertEqual((cfg["theme"], cfg["warn_at"], cfg["crit_at"]), ("dark", 15, 0))
28
+ self.assertFalse(cfg["update_check"])
29
+ self.assertIn("[ ] tokens", "\n".join(rows_text(m)))
30
+
31
+ def test_last_segment_cannot_be_turned_off(self):
32
+ for row in range(4):
33
+ apply(self.model, row, "space")
34
+ self.assertEqual(to_config(self.model)["segments"], ["tokens"])
35
+
36
+ def test_saved_file_loads_back(self):
37
+ apply(self.model, 0, "J")
38
+ with tempfile.TemporaryDirectory() as tmp:
39
+ path = Path(tmp) / "config.toml"
40
+ path.write_text(dump_toml(to_config(self.model)))
41
+ with patch.dict("os.environ", {}, clear=True):
42
+ self.assertEqual(load_config(path), to_config(self.model))
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import os
2
3
  import tempfile
3
4
  import unittest
4
5
  from pathlib import Path
@@ -137,6 +138,9 @@ class LogReaderTests(unittest.TestCase):
137
138
  day.mkdir(parents=True)
138
139
  (day / "old.jsonl").write_bytes(line(token_event(1000, 80, 10)))
139
140
  (day / "new.jsonl").write_bytes(line(token_event(2000, 5, 20)))
141
+ (day / "empty.jsonl").write_bytes(b"") # a session opened without any turn
142
+ for name, mtime in (("old", 100), ("new", 200), ("empty", 300)):
143
+ os.utime(day / f"{name}.jsonl", (mtime, mtime))
140
144
  others = newest_quotas(Path(tmp), {})
141
145
  self.assertEqual(others["5h"]["remaining"], 95.0)
142
146
  self.assertEqual(others["weekly"]["observed_at"], 2000.0)
@@ -53,6 +53,21 @@ class InstallTests(unittest.TestCase):
53
53
  str(ENTRY), manage.hook_group()["hooks"][0]["command"]
54
54
  ) # git-clone mode unchanged
55
55
 
56
+ def test_install_from_new_path_replaces_previous_install(self):
57
+ (self.root / ".zshrc").write_text("# mine\n")
58
+ manage.installation(self.args) # e.g. an old git-checkout install
59
+ tool = ["/home/u/.local/bin/codex-statusline"]
60
+ with patch.object(manage, "base_command", return_value=tool):
61
+ manage.installation(self.args) # now from uv/pipx
62
+ rc = (self.root / ".zshrc").read_text()
63
+ self.assertEqual(rc.count(manage.BEGIN), 1)
64
+ self.assertIn(tool[0], rc)
65
+ hooks = json.loads((self.root / ".codex/hooks.json").read_text())
66
+ commands = [h["hooks"][0]["command"] for h in hooks["hooks"]["SessionStart"]]
67
+ self.assertEqual(commands, [tool[0] + " hook"])
68
+ manage.installation(self.args, True)
69
+ self.assertEqual((self.root / ".zshrc").read_text(), "# mine\n")
70
+
56
71
  def test_install_repeat_uninstall_preserves_later_changes(self):
57
72
  original = "# original rc\nexport USER_SETTING=yes\n"
58
73
  (self.root / ".zshrc").write_text(original)
@@ -18,14 +18,36 @@ from src.render import visible_width
18
18
 
19
19
  class PlaceholderTests(unittest.TestCase):
20
20
  def test_no_fabricated_values(self):
21
- for theme in ("dark", "light"):
22
- with patch.dict(os.environ, {"CODEX_STATUSLINE_THEME": theme}):
23
- result = placeholder(160)
21
+ with tempfile.TemporaryDirectory() as home: # no local sessions at all
22
+ for theme in ("dark", "light"):
23
+ env = {"CODEX_STATUSLINE_THEME": theme, "CODEX_HOME": home}
24
+ with patch.dict(os.environ, env):
25
+ result = placeholder(160)
24
26
  for text in ("CTX USED", "5h", "week", "--"):
25
27
  self.assertIn(text, result)
26
28
  for text in ("status unknown", "binding pending", "0%", "100%"):
27
29
  self.assertNotIn(text, result)
28
30
 
31
+ def test_first_frame_shows_quota_from_local_sessions(self):
32
+ with tempfile.TemporaryDirectory() as home:
33
+ day = Path(home) / "sessions" / "2026" / "09" / "30"
34
+ day.mkdir(parents=True)
35
+ now = time.time()
36
+ limits = {
37
+ "primary": {"used_percent": 30, "window_minutes": 300, "resets_at": now + 3600},
38
+ "secondary": {"used_percent": 60, "window_minutes": 10080, "resets_at": now + 9e4},
39
+ }
40
+ event = {
41
+ "type": "event_msg",
42
+ "timestamp": now,
43
+ "payload": {"type": "token_count", "info": {"rate_limits": limits}},
44
+ }
45
+ (day / "r.jsonl").write_text(json.dumps(event) + "\n")
46
+ with patch.dict(os.environ, {"CODEX_HOME": home}):
47
+ result = placeholder(160)
48
+ self.assertIn("70%", result)
49
+ self.assertIn("40%", result)
50
+
29
51
  def test_placeholder_follows_config_file(self):
30
52
  with tempfile.TemporaryDirectory() as tmp:
31
53
  conf = Path(tmp) / "codex-statusline" / "config.toml"
@@ -94,6 +116,8 @@ class StartupTests(unittest.TestCase):
94
116
  )
95
117
  conf = (root / "tmux.conf").read_text()
96
118
  self.assertIn("CTX USED", conf)
119
+ # Codex asks tmux for #{mouse}; off makes the wheel recall old prompts.
120
+ self.assertIn("set -g mouse on", conf)
97
121
  self.assertNotIn("binding pending", conf)
98
122
  # error.json is written before the fallback status is applied.
99
123
  # Faster Linux startup can observe that intermediate state.
@@ -27,11 +27,13 @@ class UpdateTests(unittest.TestCase):
27
27
  with tempfile.TemporaryDirectory() as tmp:
28
28
  (Path(tmp) / "uv-receipt.toml").touch()
29
29
  self.assertEqual(
30
- upgrade_command(tmp, pkg), ["uv", "tool", "upgrade", "codex-statusline"]
30
+ upgrade_command(tmp, pkg), ["uv", "tool", "install", "codex-statusline@latest"]
31
31
  )
32
32
  with tempfile.TemporaryDirectory() as tmp:
33
33
  (Path(tmp) / "pipx_metadata.json").touch()
34
- self.assertEqual(upgrade_command(tmp, pkg), ["pipx", "upgrade", "codex-statusline"])
34
+ self.assertEqual(
35
+ upgrade_command(tmp, pkg), ["pipx", "install", "--force", "codex-statusline"]
36
+ )
35
37
  self.assertEqual(upgrade_command("/nowhere", pkg)[1:3], ["-m", "pip"])
36
38
  repo = Path(__file__).resolve().parent.parent
37
39
  if (repo / ".git").exists():